diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 9ee365f446..b80381f031 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 8b4f940299cbba78d403c34b1e5fc9740e44f2c2 -2026-07-19-gui-web-client-architecture.zh.md: 705b1337dd97ac37bd01bdcc5aa22484b7971908 +2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235 +2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 8b4f940299..4448fd5c68 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -48,7 +48,7 @@ There is no component registration model besides slots — the former view and t **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). -## The data object layer (`packages/client/runtime/src/client/sessions/`) +## The data object layer (`packages/api/session-controller/src/client/`) Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 705b1337dd..e8a8121a1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -48,7 +48,7 @@ slot 之外不存在第二种组件注册模型——原视图环与工具环都 **scope 寻址**与 host 侧 agent(智能体)scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 -## 数据对象层(`packages/client/runtime/src/client/sessions/`) +## 数据对象层(`packages/api/session-controller/src/client/`) 帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言): diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml index 7a63fc4c41..c32026ebc7 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md -2026-08-08-client-tool-presentation-ownership.md: 3feefc3cfbe538024b8610394b9f170c423556e8 -2026-08-08-client-tool-presentation-ownership.zh.md: 181c57a0da61795292d70b3d37ebd1485832795b +2026-08-08-client-tool-presentation-ownership.md: 1daad1559a6c8ef15fadb8e7c8dfeb2874ae3f9a +2026-08-08-client-tool-presentation-ownership.zh.md: f980db28e1174aa95b29defb8b0a36fc0ba4cf2e diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md index 3feefc3cfb..1daad1559a 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -16,7 +16,7 @@ Tool is a first-class Client UI presentation concept. `@deepseek-ai/dsh-client-u Conversation data assembly follows the later [Conversation business-node decision](2026-08-09-client-conversation-node-assembly.md). The `ui-conversation` Tool Definition pairs root call/result Session Events, folds Code Dispatch edges into recursive `ToolCallBlock.subCalls`, and emits one stable `tool-call` Chat Node. This data responsibility handles only official Tool identity and topology; it does not interpret presentation for concrete Tool names. -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists. +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists. A business Tool plugin receives one standard `ToolCallBlock`, identity, workspace cwd, and host actions; it does not read Session, Context, or the Conversation assembler. Skill remains an ordinary Tool and uses the same keyed-slot registration path as other business Tools. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md index 181c57a0da..f980db28e1 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -16,7 +16,7 @@ Client 运行时已经按 `callId` 配对工具调用/结果事件,并能从 C Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-08-09-client-conversation-node-assembly.zh.md)。`ui-conversation` 的工具 Definition 从会话事件配对 root call/result,把 Code Dispatch edge fold 成递归 `ToolCallBlock.subCalls`,并生成一个稳定的 `tool-call` Chat Node;这里的数据职责只处理官方工具 identity 和拓扑,不解释具体工具名称的展示。 -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只按 Chat 快照的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。 +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只按 Chat 快照的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。 业务工具插件接收一个标准 `ToolCallBlock`、identity、workspace cwd 和宿主动作,不读取会话、上下文或 Conversation assembler。skill(技能)仍是普通工具;它和其他业务工具使用同一 keyed slot 注册路径。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 90160da7db..e7aa813ad5 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 69f92b906e46ae881b7aa6b5e46e998047fca8c2 -2026-08-09-client-conversation-node-assembly.zh.md: d075e009d9a04f20dbc8dda518e90b368b54286f +2026-08-09-client-conversation-node-assembly.md: e6c0e790a361265870a04ee63301b9f11940c648 +2026-08-09-client-conversation-node-assembly.zh.md: 702ddba0019e125d3976727f841db775276b3b77 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 69f92b906e..e6c0e790a3 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -33,7 +33,7 @@ Registry contributions are Cordis effects. Removing a Definition causes a low-fr ### Overall `ConversationNodeDefinition` contract -Each [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs. +Each [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs. One Event may be claimed by several ordinary Definitions. For example, an Assistant Event updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Tail. The Assembler asks the fallback only when every ordinary Definition returns `null`. @@ -160,7 +160,7 @@ IDs are never reused. Completed Contexts remain in the current window, providing ### Location is a first-class engine fact -[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`. +[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`. Location has four shapes: `session`, `turn`, `step`, and `unresolved`. Turns and Steps each carry `open`, `closed`, or `unknown` status plus any loaded start and end Events. @@ -302,19 +302,19 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface ## View Builder and React identity -[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches. +[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches. The Assembler calls `replace({ nodes, timeline })` on low-frequency complete replacements and `apply({ upserts, timeline })` for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions. -[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. +[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value. For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys. -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. -[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins still live in `ui-conversation`, but this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher. +[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins live in `ui-chat`, and this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher. The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes only the stable Node key as `hookContext`; the Slot renderer combines that key with `useSession` from the official standard props to construct `useTurnData(businessKey)`. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index d075e009d9..702ddba001 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -33,7 +33,7 @@ Registry 注册是 Cordis effect,Definition 卸载会触发现有 Session 的 ### `ConversationNodeDefinition` 总体契约 -每个 [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。 +每个 [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。 同一个 Event 可以被多个普通 Definition 认领。例如一条 Assistant Event 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Tail。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。 @@ -160,7 +160,7 @@ ID 不复用,完成的 Context 继续存在于当前窗口,既提供稳定 ### Location 是一级引擎事实 -[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。 +[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。 Location 有 `session`、`turn`、`step` 和 `unresolved` 四种形状。Turn/Step 各自带 `open`、`closed` 或 `unknown` 状态,以及已加载的 start/end Event。 @@ -302,19 +302,19 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何 ## View Builder 与 React identity -[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。 +[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。 Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。 -[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 +[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value。 Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。 -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 -[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现仍位于 `ui-conversation`,但该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。 +[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现位于 `ui-chat`,且该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。 `conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 只把稳定 Node key 作为 `hookContext` 传给 slot;Slot renderer 用官方 standard props 中的 `useSession` 和该 key 构造 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml index 50cdedbe5b..d9823a5e88 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md -2026-08-10-cancelled-stream-prefix-finalize.md: 0cae25b786922fba8204d68ca9c0a669e43d76a0 -2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c +2026-08-10-cancelled-stream-prefix-finalize.md: fd397a02663908f5984b4e1798d1b1759b140c79 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: 44adb2ff4163cd1904a9a93895c99519bae2f234 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md index 0cae25b786..fd397a0266 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -36,4 +36,4 @@ Terminal provider errors still discard their streamed prefix. That asymmetry rem ## Testing -`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications. +`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications. diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md index e961ea6a51..44adb2ff41 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -36,4 +36,4 @@ Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted` ## Testing -`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。 +`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index e1b8afe3d3..33a31a1363 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: c0bc459eb5f96dccd135417c0d5d4d2f743aad5e -2026-08-10-remote-event-delivery.zh.md: 08e3570708c20223697a186ee79e16f5ac3bda8b +2026-08-10-remote-event-delivery.md: 5e6e04bdf2c6b685bbf10f05ede9c96ea8104429 +2026-08-10-remote-event-delivery.zh.md: d744b92d47f5778397b519fa09d4b91f620dcd7e diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index c0bc459eb5..5e6e04bdf2 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -1,4 +1,4 @@ -# Agent Note: Remote event delivery (ctx.remote.$on) +# Agent Note: Remote event delivery (`ctx.remote.$on`) Status: implemented @@ -6,39 +6,51 @@ English | [中文](2026-08-10-remote-event-delivery.zh.md) ## Problem -[Typert Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy. +[Typert Remote method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) initially cover targeted calls with one result per request and deliberately leave Session streams and stateful interactions elsewhere. Host-to-consumer events need a delivery mechanism that is not owned by the API Proxy domain. -The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge. +The Host owns one-way events such as `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. They do not depend on AgentScope, and their payloads are already JSON. Requiring every event to cross a handwritten API Proxy frame, a handwritten Client Runtime bridge, and a Client event alias adds no fact beyond the owner event declaration. -That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol. +That duplicate declaration is also lossy: the Client side restates an event as `settings/changed(ns: string)`, flattening a branded type to bare `string`, contrary to the Remote-method rule that consumer types point to the business package's one canonical symbol. ## Decision -The consumer Remote surface carries one one-way subscription verb, `ctx.remote.$on(event, listener)`, driven by an allowlist and forwarding verbatim: +The consumer Remote surface has one event-subscription verb, `ctx.remote.$on(event, listener)`, with allowlist-driven, verbatim forwarding: -- `packages/api/remotes/src/remote-events.ts` holds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to. `src/types.ts` beside it derives the type projection and fills the selection seat, staying type-only per the package convention. Both files are listed in the `files` of **both** of this package's faces, so the Host forwarding loop and the consumer key surface read one declaration. -- The wire event name **is** the Host cordis event name (`settings/document-updated`) with no `host/` prefix, and the payload **is** the Host argument list, element for element, with no projection, redaction, or renaming. -- The carrier reuses the existing host stream: `HostFrame` gains one wrapper variant, `host/remote-event`. No new downlink. -- Event **signatures** get no second table. Each owner package moves its cordis `Events` declaration into its client-safe, type-only `./types` export, so both faces read the same declaration and `$on`'s listener type is `Events[Event]` itself. "Verbatim" then holds by construction rather than by proof. -- Only cordis's *type shape* is borrowed, not its event system: delivery semantics, the subscription registry, and failure containment belong to Typert. +- `packages/api/remotes/src/remote-events.ts` owns one list of forwardable Host events with explicit `emit`/`waterfall` modes. It is also the sole control point for what consumers may subscribe to. Adjacent `src/types.ts` derives the type projection and fills the selection seat while remaining type-only. Both files appear in the `files` of the package's Host and Client faces, so both read one declaration. +- The event name on the wire is the original Host Cordis name (`settings/document-updated`) without a `host/` prefix. The payload is the Host argument list, element for element through JSON, without projection, redaction, or renaming. +- `api/remotes` registers the Host source with API Gateway. Gateway reserves internal logical endpoint `$events` on the existing `/api/remote.mux`, adding no physical connection and giving API Proxy no event interpretation. Waterfall results return through HTTP unary endpoint `$events/result`. +- Event signatures have no second table. Owner packages place their Cordis `Events` declarations in Client-safe, type-only `./types` exports so both faces read the same declaration. `$on` listener parameters, result, and `next()` derive from `Events[Event]`; verbatim correspondence holds by construction. +- Only Cordis's type declarations are shared. Delivery semantics, registration, and failure handling belong to Typert. -When an `Events` entry's signature reaches a Host-only symbol (a Service, `Agent`, a Context), the answer is to **split the code until the entry lands cleanly in `./types`** — never a declaration half-left in `index.ts`, and never a structurally equivalent shadow type in `./types`. None of the five packages needs that here: their entries reach only `SettingsNamespace`, `SettingsUpdateSource`, `CredentialRef`, and `SessionId`, all pure types. The agent-presets package renames its previous vocabulary module to `preset.ts`, leaving the exported `types.ts` dedicated to the client-safe event declaration. +When an `Events` member reaches a Host-only symbol such as a Service, `Agent`, or Context, the code is split until the declaration can live cleanly in `./types`. A declaration is never split between `index.ts` and `types.ts`, and `types.ts` does not invent a structurally equivalent shadow type. Every current owner exposes its selected event declaration from a Client-safe type export. -All five events ride this path, and their dedicated `HostFrame` variants or Client aliases are gone. Model consumers subscribe directly to both owner inputs, `llm/adapters-updated` and `settings/document-updated`; preset-derived consumers subscribe to `agent-preset/selected`. Frames that actually project or deduplicate data stay dedicated: `host/workspace-changed`/`-removed`/`host/archived-sessions-changed` (view derivation plus per-connection dedup state), and `host/session-added`/`-removed`/`host/session-status`/`host/agent-error` (live-object projection or frame-time derived fields). +All allowlisted events use this path, and dedicated frames and Client aliases are removed. Model consumers subscribe directly to `llm/adapters-updated` and `settings/document-updated`; preset consumers subscribe to `agent-preset/selected`; stateless Session and dynamic-Cordis notifications use `emit`; Approval and Question use Agent-scoped `waterfall`. Data that needs a baseline, projection, or deduplication retains a dedicated Remote stream. -`skills/change`, `tools/change`, and `system-prompt/change` have the same shape but **no shipped consumer**; under "require a current owner and need" they stay out of the allowlist and are recorded here only as the extension seat. +`skills/change`, `tools/change`, and `system-prompt/change` have the same pure invalidation form but no shipped consumer. The rule that every abstraction needs a current owner and need keeps them outside the allowlist; they remain only an extension point recorded here. -### Consumer contract (dsh-typert-protocol) +### Consumer contract (`dsh-typert-protocol`) -type-meta gains one **shape predicate**, one **selection seat**, and **one** member on `TypertClientRemote`. No runtime code: +Type metadata adds event-form predicates, mode entries, a selection seat, and one member of `TypertClientRemote`, with no runtime code: -```ts +```ts ignore-check import type { Events } from '@deepseek-ai/cordis' -/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */ +type TypertForwardingMode = + unknown extends ThisParameterType + ? TypertEventResult extends void ? 'emit' : never + : TypertWaterfallEvent extends never ? never : 'waterfall' + +/** Cordis event names that can cross the Remote Event carrier without a second signature. */ export type TypertForwardableEvent = { - [Event in keyof Events]: unknown extends ThisParameterType - ? ReturnType extends void ? Event : never + [Event in keyof Events]: TypertForwardingMode extends never ? never : Event +}[keyof Events] + +/** Event and dispatch mode accepted by the Remote Event source. */ +export type TypertForwardableEventEntry = { + [Event in keyof Events]: TypertForwardingMode extends infer Mode + ? Mode extends 'emit' | 'waterfall' + ? { readonly event: Event; readonly mode: Mode } + : never : never }[keyof Events] @@ -51,126 +63,136 @@ export type TypertRemoteEvent = Extract(event: Event, listener: Events[Event]): () => void +$on(event: Event, listener: TypertClientEventListener): () => void ``` -`Events` resolves per program: the full Host vocabulary in the Host program, whatever the Client face can see in the Client program. The same predicate therefore holds on both sides without dragging Host declarations into the Client. +`Events` resolves per program: the complete Host event vocabulary in a Host program and only declarations visible to the Client compilation face in a Client program. The same predicate therefore holds on both sides without bringing Host declarations into the Client. -**The surface separates the consumer verb from the carrier handoff**: consumers subscribe with `$on`, and whoever owns the Host frame sink hands each decoded frame over with `$dispatch`. It cannot be a module-level function reaching across Client plugins — the client bundle purity gate (`packages/client/tsdown.client.ts`) admits value imports only from the implicit `PLATFORM_MODULES` plus `PRELOADED_CLIENT_EXTERNALS` baseline, the package's `dsh.client.external` requests, the `INLINE_SAFE` wire layer, and generated `/remote` contributions. Inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false. A cordis service method is the collaboration shape that gate prescribes: +**The contract exposes only the consumer verb.** `ClientRemoteService` registers the one internal `$events` pump as a Connection generation source when it activates, independently of whether any `$on` subscription exists. Browsers open `$events` through the shared Remote mux; in-process compositions open the same logical stream through `connection.rpc.open`. Decoding, exact item validation, and Cordis dispatch are private Gateway Client implementation. `TypertClientRemote` exposes no producer operation, so a business plugin cannot synthesize a Host event. + +Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready' }` before iterating the event source. `ConnectionController` waits for that ready item and `host.describe` in parallel and publishes `connected` only after both succeed, so baseline reads cannot race ahead of incremental listeners. + +A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation's `hostDescription` and reopens `$events` and `host.describe` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal. + +The Client dispatches on a Cordis key private to each Remote instance. Ordinary `emit` uses `parallel()` and contains listener failures; Agent-scoped `waterfall` uses `waterfall()` on the resolved Agent Context and allows a result, rejection, or `next()` delegation. Both registration kinds belong to the calling fiber, and Host events do not trigger same-named Client-local events. + +### The allowlist: one declaration read by both faces + +`packages/api/remotes/src/remote-events.ts` appears in both `tsconfig.host.json` and `tsconfig.client.json` and is the allowlist's sole home. `src/types.ts` derives the type face: ```ts ignore-check -$dispatch(event: string, args: readonly unknown[]): void -``` - -client/runtime — the owner of the host frame sink — calls it directly, so the frame reaches the subscription table without an intermediate event to relay it. The `event` parameter is `string`, not `TypertRemoteEvent`: this is a wire boundary, and a name nobody subscribed to is dropped silently. - -Delivery shares no implementation with the cordis event system: one-way only, no waterfall/bail/parallel/serial modes and no `@mode` concept (`ReturnType extends void` is the static expression of that rule), no `this` binding, no `EventOptions`, `prepend`, or priority. Listeners run in registration order, and one that throws is contained and logged — it must never take down the frame pump (the same posture `ConnectionController` already applies to its sinks). - -### The allowlist: one declaration both faces read - -`packages/api/remotes/src/remote-events.ts` is listed in the `files` of both `tsconfig.host.json` and `tsconfig.client.json`, and is the allowlist's single home; `src/types.ts` derives its type face: - -```ts // remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ - 'agent-preset/selected', - 'commands/change', - 'credentials/reference-updated', - 'llm/adapters-updated', - 'settings/document-updated', -] as const + { event: 'agent-preset/selected', mode: 'emit' }, + { event: 'approval/request', mode: 'waterfall' }, + ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })), + { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/reference-updated', mode: 'emit' }, + { event: 'cordis/request-run', mode: 'emit' }, + { event: 'cordis/request-run-resolved', mode: 'emit' }, + { event: 'cordis/dynamic-package', mode: 'emit' }, + { event: 'cordis/dynamic-retract', mode: 'emit' }, + { event: 'cordis/inspect-query', mode: 'emit' }, + { event: 'cordis/inspect-query-resolved', mode: 'emit' }, + { event: 'llm/adapters-updated', mode: 'emit' }, + { event: 'settings/document-updated', mode: 'emit' }, + { event: 'user-questions/request', mode: 'waterfall' }, +] as const satisfies readonly TypertForwardableEventEntry[] // types.ts — the type face, derived -export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event'] declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertRemoteEventSelection extends Record {} } ``` -Forwarding one more event is therefore **one line in that array**: the type projection, `$on`'s key surface, and the Host forwarding loop all derive from it. `ctx.remote.$on('slots/changed', …)` (a Client-local event) and `$on('skills/change', …)` (declared but unselected) are both **compile errors**. +Adding an event is therefore one array entry: type projection, the `$on` key set, Host dispatch mode, and the forwarding loop all derive from it. `ctx.remote.$on('slots/changed', …)` for a Client-local event and `$on('skills/change', …)` for a declared but unselected event are compile errors. -The Host face adds one shape assertion, binding the Host event vocabulary to that same array: +The declaration's trailing `satisfies` applies Host event-vocabulary and mode constraints to the same allowlist: ```ts ignore-check -API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[] +API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEventEntry[] ``` -It is an expression statement rather than a named constant, which `noUnusedLocals` would reject (the underscore prefix exempts parameters only). It enforces three things: the **name is real** (the predicate is keyed on `keyof Events`), the event **binds no Scope** (`goal/changed` and kin have a `ThisParameterType` other than `unknown` and drop out — the static expression of "no AgentScope dependency"), and the event is **one-way** (a non-`void` return, i.e. a waterfall/bail shape, drops out). +It enforces three properties: the name exists because the predicate is keyed by `keyof Events`; the selected mode matches the signature; and the signature is either an unscoped `void` notification or a waterfall with top-level Agent scope, a same-result `next()`, and a Promise return. Other Scope, bail, parallel, and serial forms are excluded. -**"Verbatim" is proved nowhere because it holds by construction**: `$on`'s listener type comes from the one cordis `Events` declaration in the owner package's `./types`, and Host forwarding reads that same declaration. There is no second declaration that could drift. +Verbatim correspondence is not proved separately because it holds by construction. `$on`'s listener type and Host forwarding both read the owner package's one Cordis `Events` declaration, so no second declaration can drift. -JSON-safety is a runtime concern: before forwarding, apiproxy validates each argument with `dsh-session`'s `isJsonValue` and **throws loudly** when one fails, because that is an allowlist composition mistake rather than untrusted input. +JSON safety remains a runtime concern. Before queueing, the API Remotes Host source checks every argument with `dsh-session`'s `isJsonValue` and fails loudly when one is invalid, because this is an allowlist composition error rather than untrusted input. -### Wire contract (apiproxy) +### Wire protocol (API Gateway Remote mux) ```ts ignore-check -| { type: 'host/remote-event'; event: string; args: JsonValue[] } +ready { type, clientId } +emit { type, event, args } +waterfall { type, event, eventId, agentId, request } +cancel { type, eventId } ``` -The zod branch keeps `args: z.array(z.unknown())`: the frame arrives from `JSON.parse`, so every element is already a JSON value, and the structural contract belongs to the owner package's `Events` declaration — the same posture the existing `session/projection` frame takes with its `value`. +The Client opens internal logical stream `$events` with payload `{ args: {} }`. Gateway rejects extra parameters, a missing Host source, and duplicate source registration. Withdrawing a source aborts every stream opened by that registration. Each Client stream owns an independent queue and allowlist listener set in `api/remotes`, so disconnecting one Client neither consumes nor withdraws another Client's events. -`events.host()` subscribes by allowlist when the stream opens. Each stream owns its disposers, so no broadcast set or derived invalidation listener is needed. +The Client requires an opening `ready` item with a non-empty `clientId`; every later item is checked for exact fields by discriminant. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields. -`api/events.ts` is a wire contract file the browser side also compiles, so every type it references must come from an owner package's **client-safe, type-only subpath**, never the package root. Evidence: importing one type from `@deepseek-ai/dsh-session` root drags the root's `declare module 'cordis' { interface Context { sessions: SessionStore } }` into the Client compilation face and overrides the Client's `ctx.sessions: ISessions`, producing 18 errors in the unrelated `ui-input-trigger` and `ui-conversation`. `JsonValue` therefore needs a re-export from `dsh-session/src/types.ts`. +`$events` is an internal Gateway endpoint. It does not enter a generated Typert Remote descriptor or become `ctx.remote.`. Application selection exists only in the API Remotes allowlist and Host source; Gateway owns registration, payload validation, and physical transport only. -### The apps/web browser e2e belong to the Host face +### The `apps/web` browser e2e belongs to the Host face -The `apps/web/tests/**` e2e type-check in the root **`tsconfig.host.json`**: they boot a real harness in-process and read `ctx.apiProxy`, the Host `SessionStore`'s `get`/`create`/`flush`, and `ctx.sessionProjectionCache`. **Driving a browser at runtime does not make a file part of the Client program** — moving them into the Client aggregate immediately produces 21 errors, because one program cannot hold both faces' merges for the same Context key. +The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.apiProxy`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces 21 errors because one program cannot hold both faces' merges for the same Context key. -That yields a discipline this design depends on: **when those tests import a value or a type from a Client package, they pull that package's whole project — and every project it references — into the Host build graph**. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, `ui-commands`) reference `api/remotes`' Client face, and that face cannot compile until Host tsdown has generated `@deepseek-ai/dsh-goal/remote`. The result is a build-order deadlock: Host tsc needs the Client face, which needs the generated artifact, which Host tsdown produces after Host tsc. +This implies one build rule needed by the design: importing a value or type from a Client package in those tests brings that package's whole project and all its project references into the Host build graph. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, and `ui-commands`) reference API Remotes' Client face, which cannot compile until Host tsdown generates `@deepseek-ai/dsh-goal/remote`. That forms a build-order cycle: Host tsc needs API Remotes Client, which needs generated `goal/remote`, which Host tsdown emits after Host tsc. -The few Client-owned symbols are therefore **mirrored** on the test side (`scaffold.ts` exports the mirrored welcome-notice constants; the two chat e2e keep importing `dsh-client-runtime/client` because the `runtime` project is already in the Host graph), which lets those four consumers leave the Host graph. The 15 Client project references in `apps/cli/tsconfig.json` lost their owner-map role and are gone. Each mirrored value matches its source verbatim; a drift shows up as a missed selector or an unsuppressed notice, both loud failures. +The few required Client symbols are mirrored on the test side: `scaffold.ts` exports the mirrored welcome-notice constants, while the two chat e2e files import `dsh-client-runtime/client` directly because the Runtime project already belongs to the Host graph. This removes those four consumers from the Host graph, and the 15 Client project references in `apps/cli/tsconfig.json` no longer serve an owner-map role. Each mirror is byte-identical to its source; drift produces a selector mismatch or an unsuppressed notice and fails loudly. ### Change inventory | Location | Change | |---|---| -| `dsh-typert-protocol` | `src/types.ts` gains `TypertForwardableEvent`, `TypertRemoteEventSelection`, and `TypertRemoteEvent`; `TypertClientRemote` gains `$on` and `$dispatch`. Types only, no runtime | -| `api/gateway` Client half | `ClientRemoteService` implements `$on` (subscriptions addressed by registration, `ctx.effect` ownership for the calling fiber) and `$dispatch` (snapshot delivery in registration order, containing a listener that throws or rejects) | -| `api/remotes` | New `src/remote-events.ts` (the allowlist value) and `src/types.ts` (type projection, selection seat), both listed in both faces' `files`; a `./types` export with `lib/types/**/*.js` added to `files`; the Host face adds the shape assertion and `import type {}` for the five owner `./types`; the Client half re-exports those five plus `@deepseek-ai/dsh-api-gateway/client` | -| Root `tsconfig.base.json` | Client-safe `paths` entries for settings, credentials, llm, agent-presets, and api-remotes types point at the **source** plane | -| `dsh-commands` / `dsh-settings` / `dsh-credentials` / `dsh-llm` / `dsh-agent-presets` | Each forwarded `interface Events` member lives in the owner's client-safe `./types`; agent-presets moves its previous domain vocabulary to `preset.ts` so the exported file itself remains `types.ts` | -| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses the five dedicated passthrough or invalidation variants with their zod branches; `events.host()` subscribes by allowlist and validates through `assertJsonArgs` | -| `dsh-session` | `src/types.ts` re-exports `JsonValue` so wire contract files can use the client-safe subpath | -| `client/runtime` | The five Client-event bridge branches collapse into `ctx.remote.$dispatch(frame.event, frame.args)`, adding a `remote` injection and deleting their duplicated `Events` declarations | -| Seven consumers | ui-commands / ui-model-selection / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset / ui-skill subscribe through `ctx.remote.$on(...)`, following `ui-goal`'s precedent for the type-only facade import and the `'remote'` injection | -| `client/connection` | The fixture's `emitHost` produces `host/remote-event` | -| `apps/web/tests` + `apps/cli` | Client symbols mirrored on the test side (see above); `apps/cli/tsconfig.json` drops its 15 Client project references | +| `dsh-typert-protocol` | `src/types.ts` provides forwardable-mode derivation, selection, and Client-listener projection; `TypertClientRemote` exposes only `$on`. Types only, no runtime | +| `api/gateway` | Host provides one Remote event source, `$events`, pending-waterfall coordination, and `$events/result`; Client registers the private pump as the Connection generation source and owns frame validation and Cordis dispatch | +| `api/remotes` | `src/remote-events.ts` (mode-bearing allowlist value) and `src/types.ts` (key projection and selection) belong to both faces; Host registers each Client source and validates JSON before queueing; Client continues to compose generated Remote contributions | +| Root `tsconfig.base.json` | Adds source-plane `paths` entries for `dsh-settings/types`, `dsh-credentials/types`, and `dsh-api-remotes/types` | +| `dsh-commands` / `dsh-settings` / `dsh-credentials` | Moves each `interface Events` member to the owner's Client-safe `./types`; settings and credentials add that export, move brands and pure types with it, retain constructors in index, and include `lib/types/**/*.js` in published files | +| `host/apiproxy` | Contains no `HostFrame`, `events.host()`, or other Host downlink carrier; API Proxy does not participate in Host events or Connection generation | +| `dsh-session` | Exposes `isJsonValue` for validation of every event argument by the API Remotes Host source | +| `client/runtime` | Removes the bridge from Host frames to the Remote subscription table; it only publishes `connection/reset` after a Connection generation is established | +| Consumers | Client plugins subscribe directly through `ctx.remote.$on(...)`, import owner event declarations type-only, and inject `'remote'` | +| `client/connection` | Provides the one generation-source registration point; `ConnectionController` combines `$events` ready with `host.describe`, and the fixture emits events from the same source | +| `apps/web/tests` + `apps/cli` | Mirrors Client symbols on the test side as described above and removes 15 Client project references from `apps/cli/tsconfig.json` | ## Alternatives considered -**Open a general downlink channel for Remote events** (the push counterpart of `ctx.connection.rpc`, a third WebSocket). This best matches "Connection owns the carrier, the Gateway never touches transport", but it means a new stream in the Host downlink, `WebApiClient`, `ConnectionController`, the fixture, and the web e2e — a cost out of proportion to this change. Reusing the host stream costs a temporary tenancy inside a legacy frame union; when that stream moves, the wrapper moves with it and the consumer contract does not change. +**Continue using API Proxy's Host downlink.** This reuses Connection generation and `connection/reset` but leaves the Remote event allowlist, queue, schema, and Client Runtime bridge in API Proxy and prevents domain transports from sharing the lifecycle of other Remote streams. With API Gateway's resident `/api/remote.mux`, `$events` adds only one internal logical stream and belongs naturally in Gateway. -**Declare a separate `TypertRemoteEventMap` in type-meta and let owner packages merge into it.** The consumer key set would equal exactly "events declared remotely deliverable", but every signature would be written a second time outside cordis `Events`, requiring a bidirectional `extends` proof to stop the two from drifting, plus a new type-meta dependency for three owner packages. Sharing the one `Events` declaration makes that equivalence structural, so the table is not created. +**Open a third physical WebSocket or duplex stream for Remote events.** An independent channel could own connection state but would duplicate authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect backoff already provided by Gateway mux. Internal `$events` retains an independent logical stream, while waterfall results reuse HTTP unary calls. -**Have the typert generator project Host `Events` declarations** (codec, `.d.ts`, declaration map, like `/remote`). The generator already analyzes Host events, but it cannot see projection or redaction intent, and it would change the generator and the build surface. Verbatim forwarding needs no projection. +**Declare a separate `TypertRemoteEventMap` in type metadata and let owner packages declaration-merge into it.** The consumer key set would exactly equal remotely deliverable events, but every signature would be written again outside Cordis `Events`, requiring a bidirectional equivalence proof and new type-metadata dependencies for owner packages. Sharing one `Events` declaration makes equivalence structural, so the second map is not created. -**Give forwardable events a payload projection function** (a `{ name, project, zod }` forwarding table). This could fold the two model-directory inputs into one derived invalidation and also cover workspace view derivation, at the cost of hand-aligning projection logic with payload types — the central table the method side just removed. +**Have the Typert generator project Host `Events` declarations.** The generator already analyzes Host events, but it cannot infer projection or redaction intent and would expand the generator and build surface. Verbatim forwarding needs no projection. -**Move the apps/web browser e2e into the Client aggregate.** "Client tests belong to the Client face" looks right and fails immediately with 21 errors: those tests use Host services, and in the Client program `ctx.sessions` is `ISessions`. +**Give forwardable events a payload projection function.** A `{ event, project, zod }` table could combine model-directory inputs and derive Workspace views, but would manually align projection logic with payload types and recreate the central table removed from Remote methods. -**Split `directory-picker-browse`/`-native` into Host and Client faces** so no Client package reaches the Host graph. The direction is right — they are genuinely unsplit dual-half packages — but the change lands in another owner's packages and buys only a cleaner build graph; once this design mirrors the Client symbols on the test side, it no longer needs the split. **Assessed and declined.** +**Move the `apps/web` browser e2e into the Client aggregate.** The intuition that browser tests belong to the Client face fails with 21 errors because the tests use Host services while the Client program's `ctx.sessions` is `ISessions`. + +**Split `directory-picker-browse`/`-native` into Host and Client faces.** This would remove Client packages from the Host graph, but changes another owner's packages for only a cleaner build graph. Mirroring the required Client symbols on the test side removes the need for that split. ## Verification -What pins this behavior: - -- A real composition test puts one `host/remote-event` frame on the real host stream per Host emit, with `event` the Host name and `args` equal element for element. -- Type-level negatives reject three candidate classes: a name that is not an event, a Scope-bound event (`goal/changed`), and an event whose return is not `void`. `$on('slots/changed', …)` (Client-local) and `$on('skills/change', …)` (declared but unselected) both fail to compile, so `$on`'s key surface equals the allowlist. -- On the consumer side, `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`: the brand survives the wire. -- `$on`'s disposer belongs to the calling fiber, and two registrations of one function object retire independently — a table keyed on listener identity would collapse them, so subscriptions are addressed by registration. -- Delivery contains a listener that throws AND one that rejects a returned promise: the declared return is `void`, so nobody awaits an async listener, and its rejection would otherwise escape this containment entirely. Delivery iterates a snapshot, so subscribing or disposing mid-frame cannot change who receives that frame. -- `assertJsonArgs` is unit-tested directly rather than by driving a malformed emit through the event bus: a typed `ctx.emit` cannot construct one, since every allowlisted event has a statically JSON-safe payload. -- The five dedicated `HostFrame` variants, five Client-side aliases, and their bridge branches are absent. The model directories observe both owner inputs, while command, skill, and session-row consumers observe the preset owner's committed-selection event. +- A real Host-source composition test proves that two Client streams each receive `{ event, args }`, disconnecting one does not affect the other, and non-JSON arguments fail loudly without poisoning later valid delivery. +- Type negatives reject unselected events, non-`void` unscoped events, non-Agent-scoped waterfalls, and allowlist modes that disagree with signatures. `$on('slots/changed', …)` and `$on('skills/change', …)` both fail to compile, so `$on`'s key set equals the allowlist. +- Consumer `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`, preserving the brand across the wire. +- A `$on` disposer belongs to the calling fiber, and registering the same function object twice produces independently removable registrations; subscriptions are addressed by registration rather than listener identity. +- Ordinary notifications contain both a throwing listener and a listener returning a rejected Promise. Waterfall tests pin Client result, `next()`, rejection, cancellation, first claim across multiple Clients, and reconnect replay of a pending request. +- Gateway tests cover missing, duplicate, and withdrawn sources; payload rejection; ready-before-event ordering; and browser and in-process carriers. Client tests cover generation-source registration, description/increment readiness order, reopen after physical failure, Host errors and unexpected completion, non-ready opening items, malformed event items, `$events/result` failure, and disposal quiescence. +- `host/remote-event`, public `$dispatch`, the Client Runtime bridge, and API Proxy's allowlist dependency are absent; consumers observe owner events directly. ## Consequences -- **Tenancy inside a legacy frame union.** The contract lives in apiproxy's `HostFrame`, so a reader may assume apiproxy owns Remote events. The frame's JSDoc names `api-remotes` as the allowlist owner, and apiproxy's README records the tenancy under known limitations. When the host stream moves off that package, the wrapper moves with it and the consumer contract does not change. -- **Two files break api/remotes' face-disjointness contract.** `src/remote-events.ts` and `src/types.ts` belong to both projects, so each emits an identical declaration into the shared `lib/types`. Content is byte-identical and the `.tsbuildinfo` files stay separate, so this is harmless in practice; the README's build-boundary section states the exception and its cause (the `paths` entry points at source). -- **The carrier handoff is developer-visible.** Any Client plugin holding `ctx.remote` can call `$dispatch` and synthesize a forwarded event. That exposure predates the verb — `ctx.emit` was equally reachable while an internal event relayed the frame — and matches what `connection/reset` already allows for a fabricated reconnect; the Client is one trust domain. Tests pin the handoff-to-`$on` conversion and do not pretend the port authenticates its caller. -- **A malformed argument fails in the emitter's containment, not at load.** `assertJsonArgs` throws inside the forwarding listener, so the emitting seam's listener containment logs it and drops that frame: loud in the Host log rather than at load or at the emit point. -- **Mirrored test values can drift.** Nothing mechanically checks the Client constants mirrored in `apps/web/tests` against their source; the safety net is only that a drift misses a selector. The rule lives in `apps/web/tests/README.md` and is held by review — a grep-level gate was considered and deliberately skipped. -- **Capabilities given up.** No projected or redacted payloads, no Scope-bound events (`agentCtx.remote.$on`), and no replay on reconnect — these are pure invalidation signals, and `connection/reset` already covers refetching after a reconnect. The mux stream's session events, answerable frames, and snapshot baselines stay out of scope. -- **Client packages remain in the Host graph.** Twelve projects (`connection`, `runtime`, `ui-slots`, and kin) still reach it through the unsplit `directory-picker-browse`/`-native` pair and `api/gateway → client/connection`. They compile and no longer implicate api/remotes' Client face, so they did not block this change; splitting those packages would remove a few but was assessed and declined. The two chat e2e importing `dsh-client-runtime/client` rely on `runtime` already being in that graph — incidental, not a guarantee. -- **The invariant companion holds no runtime check.** An earlier revision asserted the dispatch shape (`thisArg === null`, `mode === 'emit'`) over the live event bus, which coupled the companion to the allowlist value and made rolldown hoist it into a third bundle chunk the mechanical publication list does not carry. The Host face's `TypertForwardableEvent` assertion already refuses both deviations at compile time, so the companion is an explained empty installer. +- **Gateway has one non-generated endpoint.** `$events` has no business namespace and does not enter the Typert descriptor. It is the internal connection point between Gateway and API Remotes and defines the Client Connection generation lifetime. Strict empty-payload validation, opening-ready validation, and single-source registration prevent it from becoming another handwritten business API. +- **Two files break API Remotes' face-disjointness rule.** `src/remote-events.ts` and `src/types.ts` belong to both projects and emit identical declarations into shared `lib/types`. Their content is byte-identical and `.tsbuildinfo` files remain separate, so this is safe in practice; the README records why source-plane `paths` require the exception. +- **Producer operations remain private.** Business plugins can call only `$on`. Host-source registration and Client dispatch are absent from `TypertClientRemote`; test doubles drive subscriptions through their own `emit` operations rather than impersonating a production API. +- **Malformed arguments fail at emit.** An API Remotes listener throws before queueing, so Host `ctx.emit` immediately observes an allowlist composition error and the queue can still deliver subsequent valid events. +- **Test-side mirrors can drift.** No mechanism compares mirrored Client constants under `apps/web/tests` with their source. Drift instead produces a selector mismatch. `apps/web/tests/README.md` records the review rule; a grep-level gate is deliberately omitted. +- **Capabilities deliberately omitted.** Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending. +- **Some Client packages remain in the Host graph.** Twelve projects, including `connection`, `runtime`, and `ui-slots`, remain reachable through unsplit `directory-picker-browse`/`-native` and `api/gateway → client/connection`. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Direct `dsh-client-runtime/client` imports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee. +- **The invariant companion intentionally has no runtime check.** A prior revision asserted delivery form on the live event bus, coupling the companion to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, so the companion is an explained empty installer. diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index 08e3570708..d744b92d47 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -6,39 +6,51 @@ Status: implemented ## 问题 -[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。 +[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)最初只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的事件需要一个不归 API Proxy 领域所有的投递机制。 -Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。 +Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 等单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。若每条事件都要穿过 API Proxy 手写帧、Client Runtime 手写桥和 Client 事件别名才能抵达 UI,这些层不会陈述 owner 事件之外的新事实。 那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。 ## 决策 -消费端 Remote 面持有一个单向事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**: +消费端 Remote 面持有一个事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**: -- `packages/api/remotes/src/remote-events.ts` 持有一份可转发 host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的 `src/types.ts` 由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件**都同时列进本包 host 与 client 两个 face 的 `files`**,两侧读同一份。 +- `packages/api/remotes/src/remote-events.ts` 持有一份带 `emit`/`waterfall` mode 的可转发 Host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的 `src/types.ts` 由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件**都同时列进本包 Host 与 Client 两个 face 的 `files`**,两侧读同一份。 - wire 上的事件名 **就是 host cordis 事件原名**(`settings/document-updated`),不加 `host/` 前缀;载荷 **就是 host 的实参列表**,逐元素原样过 JSON,无投影、无脱敏、无改名。 -- 载体**寄生现有 host 流**:`HostFrame` 加一个包裹帧 `host/remote-event`,不新开下行通道。 -- 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 类型就是 `Events[Event]` 本身。「原样」不需要证明,是构造性成立的。 +- Host source 由 `api/remotes` 注册到 API Gateway;Gateway 在既有 `/api/remote.mux` 上保留内部 logical endpoint `$events`,不增加物理连接,也不让 API Proxy 解释事件。waterfall 结果通过 HTTP 一元 endpoint `$events/result` 返回。 +- 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 参数、结果和 `next()` 都由 `Events[Event]` 推导。「原样」不需要证明,是构造性成立的。 - 但**只借 cordis 的类型形状,不接 cordis 的事件系统**:投递语义、注册表、异常处置全归 Typert 自己。 -一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。这五个包都不需要拆:它们的条目只够到纯类型。agent-presets 把原词汇模块改名为 `preset.ts`,让导出的 `types.ts` 专门承载 client-safe 事件声明。 +一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。当前名单内各 owner 都从 client-safe 类型出口提供同一份事件声明。 -五条事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 `llm/adapters-updated` 和 `settings/document-updated`;preset 消费方订阅 `agent-preset/selected`。真正需要投影或去重的数据仍保留专用帧。 +名单内事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 `llm/adapters-updated` 和 `settings/document-updated`;preset 消费方订阅 `agent-preset/selected`;Session 与动态 Cordis 的无状态通知使用 `emit`;Approval 与 Question 使用 Agent-scoped `waterfall`。真正需要 baseline、投影或去重的数据仍保留专用 Remote stream。 `skills/change`、`tools/change`、`system-prompt/change` 是同形状的纯失效事件但**没有任何已交付消费者**,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。 ### 消费端契约(dsh-typert-protocol) -type-meta 加一个**形状谓词**、一个**选择座位**和 `TypertClientRemote` 的**一个**成员;零运行时代码: +type-meta 加事件形状谓词、mode 条目、选择座位和 `TypertClientRemote` 的一个成员;零运行时代码: -```ts +```ts ignore-check import type { Events } from '@deepseek-ai/cordis' -/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */ +type TypertForwardingMode = + unknown extends ThisParameterType + ? TypertEventResult extends void ? 'emit' : never + : TypertWaterfallEvent extends never ? never : 'waterfall' + +/** Cordis event names that can cross the Remote Event carrier without a second signature. */ export type TypertForwardableEvent = { - [Event in keyof Events]: unknown extends ThisParameterType - ? ReturnType extends void ? Event : never + [Event in keyof Events]: TypertForwardingMode extends never ? never : Event +}[keyof Events] + +/** Event and dispatch mode accepted by the Remote Event source. */ +export type TypertForwardableEventEntry = { + [Event in keyof Events]: TypertForwardingMode extends infer Mode + ? Mode extends 'emit' | 'waterfall' + ? { readonly event: Event; readonly mode: Mode } + : never : never }[keyof Events] @@ -51,69 +63,78 @@ export type TypertRemoteEvent = Extract(event: Event, listener: Events[Event]): () => void +$on(event: Event, listener: TypertClientEventListener): () => void ``` `Events` 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。 -**契约把消费动词与载体交接分开**:消费方用 `$on` 订阅,持有 host 帧 sink 的一方用 `$dispatch` 把解码后的帧交进来。它**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行隐式的 `PLATFORM_MODULES` 加 `PRELOADED_CLIENT_EXTERNALS` 基座、包自身的 `dsh.client.external` 请求、`INLINE_SAFE` wire 层与 `/remote` 生成物值导入。靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。cordis 服务方法正是该门禁指定的协作形态: +**契约只公开消费动词。**`ClientRemoteService` 激活时就把内部唯一的 `$events` pump 注册为 Connection generation source,与当前有无 `$on` 订阅无关;浏览器通过共享 Remote mux 打开 `$events`,进程内组合通过 `connection.rpc.open` 打开同一 logical stream。解码、精确 item 校验和订阅表派发都是 Gateway Client 的私有实现,`TypertClientRemote` 不暴露生产方方法,因此业务插件不能伪造一条 Host 事件。 -```ts ignore-check -$dispatch(event: string, args: readonly unknown[]): void -``` +每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready' }`,再开始迭代事件 source。`ConnectionController` 并行等待该 ready 与 `host.describe`,只有两者都成功才发布 `connected` 并允许 baseline 读取。这个顺序保证 baseline 不会跑在增量 listener 前面。 -持有 host 帧 sink 的 client/runtime 直接调用它,帧不经中转事件即到达订阅表。`event` 形参是 `string` 而非 `TypertRemoteEvent`:这是 wire 边界,收到无人订阅的名字即静默丢弃。 +物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation 的 `hostDescription`,在退避后重开 `$events` 和 `host.describe`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。 -投递语义与 cordis 事件系统不共用实现:只有单向投递,没有 waterfall / bail / parallel / serial 模式,也没有 `@mode` 概念(`ReturnType extends void` 是这条纪律的静态表达);不绑 `this`;没有 `EventOptions`、`prepend`、优先级;按注册顺序逐个调用,单个 listener 抛错就地隔离并记日志——它绝不能拖垮帧泵(沿用 `ConnectionController` 对 sink 异常的既有处置)。 +Client 以 Remote 实例私有 Cordis key 分发。普通 `emit` 使用 `parallel()` 并隔离 listener 失败;Agent-scoped `waterfall` 在解析出的 Agent Context 上使用 `waterfall()`,允许结果、拒绝或 `next()` 委托。两类注册都归属调用方 fiber,且 Host 事件不会触发 Client 本地同名事件。 ### 名单:两个 face 共读的同一份声明 `packages/api/remotes/src/remote-events.ts` 同时列进 `tsconfig.host.json` 与 `tsconfig.client.json` 的 `files`,是名单的**唯一家**;`src/types.ts` 由它派生类型面: -```ts +```ts ignore-check // remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ - 'agent-preset/selected', - 'commands/change', - 'credentials/reference-updated', - 'llm/adapters-updated', - 'settings/document-updated', -] as const + { event: 'agent-preset/selected', mode: 'emit' }, + { event: 'approval/request', mode: 'waterfall' }, + ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })), + { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/reference-updated', mode: 'emit' }, + { event: 'cordis/request-run', mode: 'emit' }, + { event: 'cordis/request-run-resolved', mode: 'emit' }, + { event: 'cordis/dynamic-package', mode: 'emit' }, + { event: 'cordis/dynamic-retract', mode: 'emit' }, + { event: 'cordis/inspect-query', mode: 'emit' }, + { event: 'cordis/inspect-query-resolved', mode: 'emit' }, + { event: 'llm/adapters-updated', mode: 'emit' }, + { event: 'settings/document-updated', mode: 'emit' }, + { event: 'user-questions/request', mode: 'waterfall' }, +] as const satisfies readonly TypertForwardableEventEntry[] // types.ts — the type face, derived -export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event'] declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertRemoteEventSelection extends Record {} } ``` -于是**加一个事件只改这一行数组**:类型投影、`$on` 的键面、host 的转发循环全部从它派生。`ctx.remote.$on('slots/changed', …)`(client 本地事件)或 `$on('skills/change', …)`(名单没开)都是**编译错误**。 +于是**加一个事件只改这一行数组**:类型投影、`$on` 的键面、Host dispatch mode 与转发循环全部从它派生。`ctx.remote.$on('slots/changed', …)`(Client 本地事件)或 `$on('skills/change', …)`(名单没开)都是**编译错误**。 -host 半再加一处形状断言,把 host 事件词汇的约束落到同一份名单上: +数组声明末尾的 `satisfies` 把 Host 事件词汇与 mode 约束落到同一份名单上: ```ts ignore-check -API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[] +API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEventEntry[] ``` -写成表达式语句而不是命名常量:后者会被 `noUnusedLocals` 判为未使用(下划线前缀只豁免参数)。它卡住三件事:**名字合法**(谓词以 `keyof Events` 为基)、**不绑 Scope**(`goal/changed` 那族的 `ThisParameterType` 不是 `unknown`,被排除——「不依赖 AgentScope」的静态表达)、**单向**(非 `void` 返回的 waterfall/bail 形状被排除)。 +它卡住三件事:**名字合法**(谓词以 `keyof Events` 为基)、**mode 匹配签名**,以及只接受无 scope 的 `void` 通知或带一级 Agent scope、同结果 `next()` 和 Promise 返回的 waterfall。其他 Scope、bail、parallel 与 serial 形状都被排除。 **「原样」不在任何地方证明,而是构造性成立**:`$on` 的 listener 类型取自 owner 包 `./types` 里那一份 cordis `Events` 声明,host 转发读的是同一份,不存在可以彼此偏离的第二份声明。 -载荷 JSON-safe 交给运行时:apiproxy 转发前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。 +载荷 JSON-safe 交给运行时:`api/remotes` 的 Host source 在入队前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。 -### 线协议(apiproxy) +### 线协议(API Gateway Remote mux) ```ts ignore-check -| { type: 'host/remote-event'; event: string; args: JsonValue[] } +ready { type, clientId } +emit { type, event, args } +waterfall { type, event, eventId, agentId, request } +cancel { type, eventId } ``` -zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必然已是 JSON 值,结构契约由 owner 包的 `Events` 声明承担——与既有 `session/projection` 帧的 `value` 同 posture。 +Client 以 endpoint `$events` 和 payload `{ args: {} }` 打开 internal logical stream。Gateway 拒绝额外参数、缺失 Host source 和重复 source 注册;source 被撤回时会中止所有由该注册打开的 stream。每个 Client stream 在 `api/remotes` 中拥有独立队列与一组 allowlist listener,因此一个 Client 断开不会消费或撤销另一个 Client 的事件。 -`events.host()` 打开时按名单挂监听;每条流自持 disposers,无需新增广播集合或派生失效 listener。 +Client 要求首项是带非空 `clientId` 的 `ready`;后续 item 按 discriminant 精确校验字段。普通 `emit` 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 `eventId` 关联 `$events/result`,并由 `agentId` 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。 - -`api/events.ts` 是浏览器侧也要编译的 wire 契约文件,所以它引用的每个类型都必须走 owner 包的 **client-safe type-only 子路径**,绝不能走包根出口。实证:从 `@deepseek-ai/dsh-session` 根引一个类型,就把根出口的 `declare module 'cordis' { interface Context { sessions: SessionStore } }` 拖进 client 编译面、把 client 的 `ctx.sessions: ISessions` 顶掉,在完全无关的 `ui-input-trigger` / `ui-conversation` 里炸出 18 条错。`JsonValue` 因此需要 `dsh-session/src/types.ts` 补一条 re-export。 +`$events` 是 Gateway 内部 endpoint,不进入生成的 Typert Remote descriptor,也不成为 `ctx.remote.`。应用选择仍只存在于 `api/remotes` 的 allowlist 和 Host source;Gateway 只拥有注册、payload 校验与物理传输。 ### apps/web 的 browser e2e 属于 Host 面 @@ -127,21 +148,23 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必 | 位置 | 改动 | |---|---| -| `dsh-typert-protocol` | `src/types.ts` 加 `TypertForwardableEvent`、`TypertRemoteEventSelection`、`TypertRemoteEvent`;`TypertClientRemote` 增 `$on` 与 `$dispatch`。纯类型,零运行时 | -| `api/gateway` client 半 | `ClientRemoteService` 实现 `$on`(订阅按注册项寻址、`ctx.effect` 归属调用方 fiber)与 `$dispatch`(快照后按注册顺序派发,收容抛出或拒绝的 listener) | -| `api/remotes` | 新增 `src/remote-events.ts`(名单值)与 `src/types.ts`(类型投影 + 选择座位),两者都双列进两个 face 的 `files`;`./types` 出口 + `files` 补 `lib/types/**/*.js`;host 半加形状断言并 `import type {}` 三个 owner 包的 `./types`;client 半 `export type {}` 那三个 `./types` 与 `@deepseek-ai/dsh-api-gateway/client` | +| `dsh-typert-protocol` | `src/types.ts` 提供 forwardable mode 推导、selection 与 Client listener 投影;`TypertClientRemote` 只公开 `$on`。纯类型,零运行时 | +| `api/gateway` | Host 半提供唯一 Remote event source、`$events` stream、pending waterfall 协调和 `$events/result`;Client 半把私有 pump 注册为 Connection generation source,负责 frame 校验和 Cordis 分发 | +| `api/remotes` | `src/remote-events.ts`(带 mode 的名单值)与 `src/types.ts`(键投影 + selection)双列进两个 face;Host 半注册每 Client source,并在入队前校验 JSON;Client 半继续组合生成的 Remote contribution | | 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) | -| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删除五个专用变体及其 zod;`events.host()` 按名单挂监听并通过 `assertJsonArgs` 校验 | -| `dsh-session` | `src/types.ts` 补 `export type { JsonValue }`,让 wire 契约文件能走 client-safe 子路径 | -| `client/runtime` | 五条 Client 事件桥分支收敛为 `ctx.remote.$dispatch(frame.event, frame.args)`,并删除重复声明 | -| 5 个消费者 | ui-commands / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset 改订 `ctx.remote.$on(...)`;照 `ui-goal` 先例 type-only 引 `@deepseek-ai/dsh-api-remotes/client` 并把 `'remote'` 加进 `inject` | -| `client/connection` | fixture 的 `emitHost` 造 `host/remote-event` | +| `host/apiproxy` | 不包含 `HostFrame`、`events.host()` 或其他 Host 下行 carrier;API Proxy 不参与 Host 事件或 Connection generation | +| `dsh-session` | `isJsonValue` 供 `api/remotes` Host source 校验每个事件参数 | +| `client/runtime` | 删除 Host frame 到 Remote subscription table 的桥;只继续在 Connection generation 建立后发布 `connection/reset` | +| 消费方 | Client 插件直接订阅 `ctx.remote.$on(...)`,type-only 引入 owner 事件声明并把 `'remote'` 加进 `inject` | +| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 以 `$events` ready 与 `host.describe` 组成世代握手,fixture 也从同一 source 产生事件 | | `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 | ## 备选方案 -**给 Remote 事件新开一条通用下行通道**(`ctx.connection.rpc` 的推送对偶,第三条 WebSocket)。最符合「Connection 独占载体、Gateway 不碰传输」;但要同时改 host 下行、`WebApiClient`、`ConnectionController`、fixture 与 web e2e 各一条流,代价与本次收益不匹配。寄生 host 流的代价是新契约暂时寄居在 legacy 帧联合里——host 流将来整体搬家时它随之搬走,消费端契约不变。 +**继续寄生 API Proxy 的 Host downlink。**这样可以复用 Connection generation 和 `connection/reset`,但会让 API Proxy 保留 Remote 事件 allowlist、队列、schema 和 Client Runtime bridge,领域传输也无法随其他 Remote stream 共用生命周期。API Gateway 已有常驻 `/api/remote.mux` 后,`$events` 只增加一个 internal logical stream,不需要第三条 WebSocket,因此转移到 Gateway 的成本和所有权都更合理。 + +**给 Remote 事件另开第三条物理 WebSocket 或 duplex stream。**独立通道能拥有自己的连接状态,但会重复 Gateway mux 已经提供的认证升级、复用、取消、错误映射和退避重连。内部 `$events` endpoint 保留独立 logical stream,waterfall 结果复用 HTTP 一元调用。 **在 type-meta 立一张独立的 `TypertRemoteEventMap`,让 owner 包 declare-merge 进去**。消费端键集会精确等于「被声明为可远程投递的事件」;代价是每条事件的签名要在 cordis `Events` 之外**再写一遍**,于是需要一条双向 `extends` 的等价性证明来防漂移,还要给三个 owner 包新增 type-meta 依赖。共用同一份 `Events` 声明让等价性变成构造性成立,这张表因此不立。 @@ -157,21 +180,21 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必 钉住该行为的东西: -- 一个真组合测试:host 每 emit 一次,真实 host 流就出一帧 `host/remote-event`,`event` 为 host 原名、`args` 与实参逐元素相等。 -- 类型层负例拒绝三类候选:不是事件的名字、绑 Scope 的事件(`goal/changed`)、返回值非 `void` 的事件。`$on('slots/changed', …)`(client 本地事件)与 `$on('skills/change', …)`(已声明但未选中)都编译失败——因此 `$on` 的键面恰好等于名单。 +- Host source 真组合测试:两个 Client stream 各自收到 host emit 的 `{ event, args }`,其中一个断开不会影响另一个;非 JSON 实参会响亮拒绝且不会毒化后续合法事件。 +- 类型层负例拒绝未选择事件、非 `void` 的无 scope 事件、非 Agent-scoped waterfall,以及声明 mode 与签名不符的条目。`$on('slots/changed', …)`(Client 本地事件)与 `$on('skills/change', …)`(已声明但未选中)都编译失败——因此 `$on` 的键面恰好等于名单。 - 消费端 `$on('settings/document-updated', …)` 把 `ns` 解析为 `SettingsNamespace`:brand 穿过 wire 存活。 - `$on` 的 disposer 归属调用方 fiber;同一个函数对象订阅两次时两条注册各自独立退订——按 listener 身份做键的表会把它们合并,所以订阅按注册项寻址。 -- 投递同时收容抛出的 listener 与拒绝所返回 promise 的 listener:声明返回值是 `void`,没人 await 异步 listener,其拒绝否则会完全逃出这层收容。投递遍历快照,因此派发中订阅或退订都不会改变本帧的接收者集合。 -- `assertJsonArgs` 直接单测,而不是从事件总线造畸形 emit:类型化的 `ctx.emit` 造不出来——名单内每条事件的载荷在静态上都是 JSON-safe 的。 -- 五个专用帧、五条 Client 别名及其桥分支都不存在;各消费方直接观察 owner 事件。 +- 普通通知同时收容抛出的 listener 与拒绝所返回 Promise 的 listener;waterfall 测试固定 Client result、`next()`、拒绝、取消、多 Client 首个 claim 和重连重放 pending request。 +- Gateway 测试覆盖 source 缺失、重复注册、撤销中止、payload 拒绝、ready 先于事件,以及浏览器与进程内两种 carrier;Client 测试覆盖 generation source 注册边界、描述与增量就绪顺序、物理失败后重开、Host 错误与意外结束、非 ready 首项、畸形事件项、`$events/result` 失败和 dispose quiescence。 +- `host/remote-event`、公开 `$dispatch`、Client Runtime bridge 和 API Proxy 的 allowlist 依赖都不存在;各消费方直接观察 owner 事件。 ## 后果 -- **寄居在 legacy 帧联合里**:契约住在 apiproxy 的 `HostFrame` 中,读者可能误以为 apiproxy 拥有 Remote 事件。该帧的 JSDoc 点名名单归 `api-remotes`,apiproxy README 在 known limitations 记录这项寄居。host 流将来整体搬家时,包裹帧随之搬走,消费端契约不变。 +- **Gateway 有一个非生成 endpoint**:`$events` 不对应业务 namespace,也不进入 Typert descriptor;它是 Gateway 与 `api/remotes` 之间的内部连接点,同时定义 Client Connection generation 的存活期。严格的空 payload 校验、opening ready 校验和单 source 注册限制它不会演化成第二个手写业务 API。 - **两个文件打破了 api/remotes 的 face 互斥约定**:`src/remote-events.ts` 与 `src/types.ts` 同属两个工程,各自向共享的 `lib/types` 发射一份相同声明。内容逐字节相同、`.tsbuildinfo` 各自独立,实践上无害;README 的构建边界节陈述了这个例外及其成因(`paths` 指向源码面)。 -- **载体交接是开发者可见的**:任何持有 `ctx.remote` 的 client 插件都能调 `$dispatch` 合成一条转发事件。这个暴露面早于该动词存在——先前由内部事件中转帧时,`ctx.emit` 同样可达——与 `connection/reset` 可被伪造成重连同一量级(client 是单一信任域)。测试只钉「交接到 `$on` 的转换」,不假装该端口鉴别调用方。 -- **畸形实参在发射方的收容里失败,而非加载期**:`assertJsonArgs` 在转发监听内抛出,因此由发射 seam 自己的 listener 收容记录并丢弃该帧——响亮地出现在 host 日志里,而不是加载时或 emit 点。 +- **生产方保持私有**:业务插件只能调用 `$on`;Host source 注册和 Client 派发都不在 `TypertClientRemote` 上暴露,测试 double 以自己的 `emit` 方法驱动订阅,不伪装成生产接口。 +- **畸形实参在 emit 点失败**:`api/remotes` listener 在入队前抛出,因此调用 Host `ctx.emit` 的操作立即看到名单配置错误;队列仍可继续投递后续合法事件。 - **测试侧镜像值可能漂移**:没有任何机制核对 `apps/web/tests` 中镜像的 client 常量与其源;安全网只是漂移会让选择器失配。规则写在 `apps/web/tests/README.md`,由 review 守;grep 级门禁经评估后刻意不做。 -- **放弃的能力**:不支持投影或脱敏载荷、不支持 Scope 化事件(`agentCtx.remote.$on`)、重连不重放——这些都是纯失效信号,且 `connection/reset` 已覆盖重连后的重新拉取。mux 流的会话事件、可应答帧与快照基线不在范围内。 +- **放弃的能力**:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。 - **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。 - **invariant companion 不做运行期检查**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让 companion 与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。host 面的 `TypertForwardableEvent` 断言在编译期已拒绝这两种偏离,因此该 companion 是一个带说明的空 installer。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml new file mode 100644 index 0000000000..b0b466cfa3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +2026-08-18-session-history-and-event-transport.md: 206d3d13d1f183b97b02b89644b022367be2ebfd +2026-08-18-session-history-and-event-transport.zh.md: 3d0b5d4ab768ecd3877bfde86822245d0b63ac49 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md new file mode 100644 index 0000000000..206d3d13d1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -0,0 +1,369 @@ +# Agent Note: Session history, control state, and Remote event transport + +Status: implemented + +English | [中文](2026-08-18-session-history-and-event-transport.zh.md) + +## Problem + +The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay. + +These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; queue, jobs, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation. + +Observing Session history, lists, and projections must allow cold reads. If transport performs a general Typert lookup whenever an argument contains a Session or Agent, opening a page, switching tabs, or reconnecting the network implicitly resumes an Agent, so observation gains execution side effects. + +Commands such as prompt, create, fork, and model selection do need to create or resume an Agent according to their own semantics. Activation authority must belong to each Remote method, not be decided implicitly by the carrier, parameter types, or a shared lookup. + +The legacy API Proxy all-Session mux, `HostFrame`, and Workspace notifications encode domain data, baselines, errors, and connection lifecycle in one handwritten protocol. Each additional state duplicates frame declarations, a Client bridge, reconnect handling, and cleanup logic, while API Proxy cannot return to owning only business methods that have not yet migrated. + +Host-to-Client Cordis events also have two invocation modes. Ordinary notifications only need broadcast delivery; Agent-scoped waterfalls such as Approval and Question must let a Client claim, delegate through `next()`, return a result, or reject while preserving one Host invocation identity across multiple Clients, disconnects, and cancellation. + +These requirements need one general transport lifecycle without making Gateway understand Session, Workspace, Approval, or Question business data. + +## Decision + +API Gateway owns Remote transport, stream lifecycles, and Remote Event coordination. Session Controller and Workspace Controller own their Host APIs, wire types, and Client domain adapters. Client Runtime only composes and consumes these objects; it does not implement another carrier state machine. + +Current ownership is: + +```text +[client/connection] +|-- Host description +|-- Connection generation +`-- unary RPC transport + +[api/gateway/client] +|-- RemoteStream +|-- RemoteSnapshotStream +|-- RemoteJournalStream +`-- ctx.remote.$on + $events pump + +[api/session-controller] +|-- ctx.remote.session unary commands +|-- session.control snapshot stream +|-- session.page + session.follow journal +`-- Session Client adapters + +[api/workspace-controller] +|-- ctx.remote.workspace unary commands +|-- workspace.follow snapshot stream +`-- Workspace Client model and adapter + +[api/remotes] +`-- application Remote Event allowlist and Host Cordis source + +[client/runtime] +`-- compose Session and Workspace domain state for consumers +``` + +API Proxy owns neither the Session or Workspace Remote namespace nor the Host downlink event carrier. `/api/events.host`, `HostFrame`, `stream/error`, `ServerRequest`, and their WebSocket/SSE branches do not participate in this data path. + +### Connection generation and physical connections + +The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream. + +After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`. + +In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux. + +The Gateway-internal `$events` logical stream is the sole generation source for `ConnectionHandle`. It does not depend on whether any business `$on` subscription exists, so connection health does not vary with the number of UI listeners. + +The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends `{ type: 'ready' }` with a `clientId`; this frame proves that the current generation can receive increments. + +`ConnectionController` waits for `$events` readiness and `host.describe` in parallel. It publishes `connected` only after both complete, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. + +Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws `hostDescription`, then re-establishes `$events` and `host.describe` after backoff. + +Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state. + +Plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. + +### General Remote stream model + +Gateway Client provides three React-independent, single-consumer lifecycle objects: + +```text +RemoteStream +|-- RemoteSnapshotStream +`-- RemoteJournalStream +``` + +Domain Controllers use them through composition or thin adapters; Session and Workspace do not inherit a common Controller base class that knows domain frames. + +#### `RemoteStream` + +`ctx.remote.$stream(options)` returns a `RemoteStream` responsible for reopening, cancellation, and disposal of one logical stream across physical generations. + +Each item carries a monotonic generation, that generation's `AbortSignal`, and `accept()`. A domain consumer calls `accept()` only after validating the opening cursor or baseline. + +Only `RemoteStreamCarrierError` permits retry. When the Host remains available, one independent reopen is allowed; otherwise the stream waits for a new Connection generation. Business errors, protocol errors, and opening failures terminate immediately. + +`restart()` replaces only the current physical generation and preserves the logical stream. `dispose()` permanently ends the logical stream, pending retry, and iterator, then waits for quiescence. + +`RemoteStream` does not understand baselines, deltas, pages, cursors, sequence numbers, or any domain frame. + +#### `RemoteSnapshotStream` + +`RemoteSnapshotStream` requires each generation to start with exactly one complete snapshot, followed only by deltas. + +An update before the snapshot or a second snapshot in the same generation is a terminal protocol error. + +The generation is accepted only after its snapshot has been applied successfully. The previously published state remains readable while the carrier reconnects, and the new generation's snapshot replaces the old mirror atomically. + +The domain adapter supplies frame discrimination, snapshot replacement, a delta reducer, carrier state, and a terminal failure sink. The general layer parses no Session or Workspace fields. + +Session control and Workspace state each use an independent `RemoteSnapshotStream`. + +#### `RemoteJournalStream` + +`RemoteJournalStream` combines one live follow with a page method in the same namespace. It applies to an append-only journal with a stable order, paginated history, and a live tail. + +Initial opening establishes follow and obtains its opening cursor before reading the initial page. Live entries produced while the page request is pending already enter the follow queue, closing the race between reading history and subscribing afterward. + +The general layer removes overlap between the page and queued entries by cursor, verifies continuity, and publishes one complete window after the page covers the opening cursor. + +Contiguous live entries publish `append`; older history pages publish `prepend`. Reconnect, cursor jumps, or continuity that cannot be proven trigger a tail-page repair. + +The old window remains readable during repair. The page and live entries accumulated during that read form a continuous window and publish one `replace`, never exposing a half-repaired state. + +If a page request is canceled with its physical carrier generation, the journal waits for the next generation's opening cursor and rereads the page at that cursor. This cancellation does not leak to the domain object as a terminal page failure. + +`RemoteJournalStream` owns the opening cursor, resume cursor, pagination, reconnect catch-up, overlap removal, and gap repair. A domain Session object does not copy these state machines. + +### Session Controller + +`packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace. + +It owns Session list, search, create, models, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. + +The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner. + +Other Host Remote namespaces reuse the same identity rules through `ctx.sessionController.inspect()` or `resolveAgent()`; they do not retain a second Session resolver. + +#### Activation policy + +Session Remote methods pass `SessionId` or `SessionAddress`; parameter types do not trigger a general Typert Session lookup. + +Each method explicitly selects a cold inspection, live-only lookup, or resume-capable resolution: + +| Operation | Source or result without a live Agent | Activation rule | +|---|---|---| +| `session.list`, `search` | persistence, projection cache, or cold log | Never resumes an Agent | +| `session.page(address)` | attached Session or persistence log | Never resumes an Agent | +| `session.follow(address)` | cold-read current cursor, then wait for future appends | Neither opening nor waiting resumes an Agent | +| `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent | +| `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent | +| `session.updateQueue`, `cancel` | only the current live Agent | Does not resume vanished state | +| `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it | +| `create` and fork target | new Session/Agent | The user command supplies creation authority | + +Reading titles, lists, and projections does not require an Agent. An observation operation cannot inherit resume authority merely because another Remote endpoint uses Agent lookup. + +#### Session journal + +`session.page` returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit `throughSeq`; this value comes from the corresponding `session.follow` generation's opening cursor and fixes the read at the same log cut. A tail page without `beforeSeq` must end exactly at `throughSeq`, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. + +The tail page also carries a projection baseline no later than `throughSeq`; older pages carry only historical entries. The Client merges pages and subsequent live control updates by projection watermark. + +Ordinary Sessions and direct subagents use one `SessionAddress` protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone. + +`session.follow` installs `session/event` and `session/created` listeners before checking an attached Session or persistence, then reads the current cursor. + +The first follow response is `{ type: 'opened', cursor }`. A generation with `afterSeq` first replays the missing suffix from the authoritative log, then emits commits buffered during the read in sequence order. + +A cold Session can open history immediately and keep follow waiting. Future events appear only after another explicit command resumes the Agent. + +Client `SessionEventStream` extends `RemoteJournalStream` and supplies only `session.follow`, `session.page`, the Session sequence algorithm, and repair requests. The general layer first obtains opening cursor `C`, then calls `session.page({ throughSeq: C })`; entries `C + 1...` received during the read remain in the follow queue, and the page must cover exactly through `C` before the layer merges and publishes a continuous sequence. + +```text +ctx.remote.session.follow(address, afterSeq?) --------| + |[]> SessionEventStream +ctx.remote.session.page(address, throughSeq, pageArgs) -| |-- replace(window) + |-- prepend(history) + `-- append(live entry) +``` + +Each Client Session owns only one current `events: SessionEventStream | undefined`. The read-only `SessionEventSource` gives the materialized event window to Conversation consumers. + +A Session's `openGeneration` only prevents an asynchronous result retired by resync, address replacement, or disposal from writing into current state. It does not participate in transport retry. + +A terminal failure from the initial page, repair page, or follow enters the current Session's `openError`. A stale business epoch or stale stream cannot overwrite newer state. + +#### Session live control + +`session.control()` is a Host-wide snapshot stream. One browser can observe transient state for all current live Sessions without opening a journal for every transcript. + +Each generation emits a complete baseline first, followed by queue, jobs, and projection deltas. The baseline reads attached Agents and process-local registries without resuming cold Agents. + +Queue and jobs use complete replacement values and apply last-wins. Agent attach, detach, Session disposal, and owner disposal can all clear a stale mirror through an empty value or a new baseline. + +The original `approval/request` and `user-questions/request` events are forwardable waterfalls. If an Agent-scoped Client listener claims a request, it returns directly. If all delivered Clients call `next()`, the original Cordis waterfall continues to later Host listeners. Session control neither stores nor replays these requests. + +The projection baseline and a tail page's log cut are produced independently. The Client always retains the value with the higher sequence number. Subscribing to live projection does not start an Agent merely to obtain a value. + +Session added, removed, activity, running status, and Agent error without a turn position do not enter the stateful control stream; they are `ctx.remote.$on` notifications that are either repairable from a list baseline or need no replay. + +Session-list `updatedAt` is `max(header.createdAt, sessionListMetadata.lastPromptAt)`. Only a user-originated `user/message` updates `lastPromptAt`; it can be recovered from a cold projection and does not depend on whether a browser follows that Session. + +### Workspace Controller + +`packages/api/workspace-controller` provides Host `ctx.workspaceController` and the generated `ctx.remote.workspace` namespace. + +It owns create, rename, delete, insertBefore, insertSessionBefore, archiveSession, and `follow`. Workspace registry remains the durable source of truth; the Controller owns Remote commands, projection, and error mapping. + +`WorkspaceFeed` synchronously observes storage `domain/changed`, and each follow generation emits a complete baseline before `upsert`, `remove`, `order`, and `archived` deltas. + +A complete `order` frame is authoritative for Workspace ordering. It avoids having the Client infer display order from upsert arrival order and converges after a reconnect baseline. + +`createWorkspaceStateStream()` assembles `workspace.follow` as a `RemoteSnapshotStream`. Client Runtime only starts and owns that stream. + +`ClientWorkspaceModel` lives on Workspace Controller's Client face. It owns baseline/increment parsing, the materialized list, the archived set, command-result echo, and merge rules for races between unary and stream arrivals. + +A successful unary command can update the local model immediately; a later stream commit still corrects state with the Host projection and complete order. Deleted Workspace ids are recorded so a delayed result cannot reinsert them. + +```text +ctx.remote.workspace.follow() -|[]> RemoteSnapshotStream + |-- replace(baseline) + |-- upsert/remove(view) + |-- replace(order) + `-- replace(archived ids) +``` + +Workspace Remote methods, state feed, and Client data model do not pass through API Proxy or depend on `host/workspace-*` notifications. + +### Remote Event + +Remote Event reuses owner packages' Cordis `Events` declarations. The original Host event is the sole business signature, and Client `ctx.remote.$on(event, listener)` derives its parameters, waterfall result, and `next()` from that declaration. + +The allowlist in `packages/api/remotes` is the sole source of application selection. Each entry explicitly marks `emit` or `waterfall`; this mode determines Host listening, the legal Client key set, and the wire frame type together. + +The system declares no `RemoteInvocationMap`, requires no second Client `@Remote`, and does not infer invocation mode by checking whether the final runtime argument is a function. + +Remote Event downlink frames form an explicit discriminated union: + +```text +ready { type, clientId } +emit { type, event, args } +waterfall { type, event, eventId, agentId, request } +cancel { type, eventId } +``` + +Both WebSocket JSON and in-process carrier entry points start from `unknown` and validate the discriminant plus exact fields. Dispatch after validation accepts only the typed union. TypeScript static types do not replace wire validation. + +Ordinary `emit` arguments must be lossless JSON. The Client calls `parallel()` on a Cordis key private to each Remote instance, preserving registration order, calling-fiber ownership, and listener-error isolation. + +The private key prevents Host events and same-named Client-local Cordis events from triggering one another. Client Remote maintains neither its own subscription registry nor a handwritten listener chain. + +Returning waterfalls currently support Agent scope only. The event signature must contain one request with a direct `agent` field followed by a `next()` returning the same result type, and the whole event returns a Promise. + +The Host projects only top-level `agent` and `signal` fields from the request: `agent` becomes top-level `agentId` in the frame, `signal` becomes the delivery lifetime, and all remaining fields must be lossless JSON as a whole. + +The Client synchronously resolves or materializes an Agent Context from `agentId`, restores the current delivery signal into the request's direct `signal` field, and invokes Cordis `waterfall()` on the target Context's private key. Before the first successful Session-list baseline, the Session-backed adapter lets transport materialize a scope; after that baseline, the list lifecycle owns scope liveness. + +The system does not scan arbitrarily deep objects, transmit path arrays or placeholders, deep-clone/restore Context and AbortSignal, or wait for a future Agent Context. + +When no Client adapter is registered, its resolver returns no Context, or resolution throws, that Client immediately returns `next`. It does not subscribe to a registry, recheck races after resolution, or create a temporary Fiber for one delivery. + +Gateway Host retains `eventId`, the Host continuation, and delivered Client generations for every unfinished waterfall. A new Client generation receives a replay of the same pending event. + +Each generation's queue guarantees one delivery, so the Client stores no `seen` set. `clientId + eventId` binds a result to the current generation; a reply from an old connection cannot complete delivery on a new one. + +When several Clients receive a waterfall, the first result or rejection completes the Host invocation and sends `cancel` to the other Clients. Gateway continues the original Cordis chain only after every delivered Client returns `next`. + +Host caller-signal cancellation, Agent Context disposal, Client-generation completion, and losing-Client cancellation all terminate their corresponding waits. + +The Client returns `next`, result, or rejection through the existing HTTP unary RPC `$events/result`; downlink events continue to share the Remote WebSocket mux, with no duplex WebSocket for responses. + +Gateway only verifies that a waterfall return value has a lossless JSON representation; it does not interpret business fields. Semantics such as whether a Question answer belongs to an offered option remain owned by the requester or UI domain and are not revalidated by transport. + +When `UserQuestionService` observes that the caller's `AbortSignal` was canceled during a request and the provider threw an ordinary error, it normalizes that failure to `UserQuestionError` with `ASK_ABORTED` while retaining the original error as `cause`. A domain error already supplied by the provider preserves its identity. + +A failure of `$events/result` fails the current Connection generation. Host withdraws that Client's delivery with the generation, the pending event is replayed in the next generation, and Client maintains no second result-retry queue. + +Ordinary `$on` notifications are not replayed after disconnect. State whose correctness depends on recovery must have a query, cursor, or opening baseline and cannot rely on eventual Remote Event delivery. + +An event is not replayed when its Client listener registers after arrival. HMR has no dedicated redelivery semantics. + +### API Proxy's remaining boundary + +Session Controller and Workspace Controller provide generated Remote namespaces directly; API Remotes and API Gateway provide Host-to-Client events directly. + +Client Connection maintains only Host generation, description, and generic RPC. It does not parse domain frames. + +Client Runtime only receives domain changes produced by Controller adapters. It recognizes no `HostFrame`, `session/subscribed`, `session/event` mux frame, or `host/workspace-*` frame. + +API Proxy carries only independent business APIs it owns. Session, Workspace, Remote Event, and Connection generation do not depend on it. + +## Alternatives considered + +**Resume an Agent whenever any Session stream opens.** Viewing history, reading a title, reconnecting a tab, or observing background state would gain execution side effects, and multiple browsers could trigger duplicate resumes. Cold logs and projections already have persistence sources. + +**Permit `session.follow` only for live Agents.** The first transcript render would have to resume an Agent or reintroduce the race between unary history and live subscription. Following by identity before a cold read covers both history and future explicit activation. + +**Split Session transport and Session commands into two public packages.** Both depend on Session address, Agent activation policy, subagent ownership, error mapping, and Client mount ordering. One public Controller preserves unified ownership while internal classes can evolve independently. + +**Move queue, jobs, projection, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`. + +**Make every domain Controller inherit a page/follow/retry base class.** Session journals and Workspace snapshots have different opening, recovery, and ordering rules. Gateway's three compositional stream objects reuse transport lifecycle while domain adapters declare only their own frame semantics. + +**Declare a separate Client invocation map for Remote Event.** A second map or Client `@Remote` would copy owner Cordis event signatures and create a drift point. Deriving `$on` listeners and results from the same `Events` declaration preserves equivalence by construction. + +**Project Agent scope through arbitrary object depth.** Recursive Context and AbortSignal scans need path, placeholder, clone, and restore protocols and turn incidental object structure into a wire promise. Top-level `agent` and `signal` cover current waterfalls. + +**Wait for a Client Agent Context or adapter before dispatching.** Registry waiters, post-resolution race checks, and temporary delivery Fibers add lifecycle to a Client that can synchronously resolve or materialize its target. Returning `next` when the resolver cannot provide a target immediately preserves Cordis waterfall semantics. + +**Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection. + +**Retain API Proxy's Host mux.** This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently. + +**Update Session list time from aggregate `session/event`.** List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable `lastPromptAt` projection expresses the ordering fact directly. + +## Verification + +Gateway mux tests pin connection without logical streams, idle residency, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. + +Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure. + +`RemoteStream` tests pin single consumption, retry reset after opening acceptance, generation-only `restart()`, no retry for terminal errors, and disposal quiescence. + +`RemoteSnapshotStream` tests pin exactly one opening snapshot per generation, rejection of an update before a snapshot, rejection of duplicate snapshots, and reconnect replacement. + +`RemoteJournalStream` tests pin follow-before-page, opening-overlap removal, contiguous append, historical prepend, reconnect catch-up, gap repair, and one atomic replacement. + +Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection. + +Session control tests pin baseline-first delivery, no cold-Session resume, attach/detach cleanup, queue and jobs replacement, and the projection watermark. + +Session Client tests pin one journal owner per Session, no writeback from stale open epochs, independent cancellation of control and journal, and retaining the published window during carrier retry. + +Workspace Host tests pin baseline-first delivery, upsert/remove, authoritative order, archived set, and follower disposal. + +Workspace Client tests pin snapshot replacement, unary/stream races, no resurrection after delete, stable ordering, and terminal failure. + +Remote Event type tests reject unselected events, non-void unscoped events, non-Agent-scoped waterfalls, and modes that disagree with signatures. + +Remote Event Host tests pin listener-before-ready, payload validation, pending replay, first result across multiple Clients, all-next delegation, rejection, Host cancellation, Context release, and losing-Client cancellation. + +Remote Event Client tests pin instance-private keys, Cordis registration order, Agent Context resolution, `next`, result, rejection, cancellation, rejection of stale-generation replies, and Connection-generation failure when `$events/result` fails. User Question tests pin normalization of in-progress signal cancellation and preservation of its cause. + +Missing, duplicate, and withdrawn sources; non-ready first items; unknown discriminants; extra fields; and non-JSON values all fail loudly at their respective wire entries. + +Static checks pin that API Proxy exports no Session/Workspace Host-frame carrier and Client Runtime contains no corresponding bridge. + +## Consequences + +The browser can read and follow a durable Session while its Agent is stopped. Observation does not implicitly resume execution; only explicitly authorized Session commands create or resume Agents according to their own rules. + +Durable logs repair a missing suffix by sequence number and page; Session control and Workspace state converge through opening snapshots; ordinary Remote Events promise no replay. Recovery semantics follow the data kind instead of imitating one another. + +Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation. + +Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary. + +The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller. + +Remote waterfalls preserve first claim across multiple Clients, continuation of the Host chain after every Client calls `next`, reconnect replay of pending calls, and end-to-end cancellation. The current protocol supports only top-level Agent scope and lossless-JSON requests and results. + +This decision extends the allowlist and single Cordis-signature design from [Remote event delivery](2026-08-10-remote-event-delivery.md): ordinary notifications use `emit`, while Agent-scoped async waterfalls use the same `ctx.remote.$on` surface with explicit `waterfall` mode. It creates no second invocation map. + +This decision takes over the Session, Workspace, and Host-event carriers retained by [simple unary API Proxy migration](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md) while preserving the complete jobs snapshot, process-local lifecycle, and “observation does not resume an Agent” semantics required by [background job display](../feature/2026-08-08-web-background-job-display.md). diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md new file mode 100644 index 0000000000..3d0b5d4ab7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -0,0 +1,369 @@ +# Agent Note: 会话历史、控制状态与 Remote 事件传输 + +Status: implemented + +[English](2026-08-18-session-history-and-event-transport.md) | 中文 + +## 问题 + +浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。 + +这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;queue、jobs、Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。 + +观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。 + +prompt、create、fork、模型选择等命令又确实需要按各自语义创建或恢复 Agent。激活权限必须属于具体 Remote 方法,而不能由 carrier、参数类型或共享 lookup 暗中决定。 + +旧 API Proxy 的全 Session mux、`HostFrame` 与 Workspace 通知把领域数据、baseline、错误和连接生命周期编码进同一手写协议。每增加一种状态都要复制帧定义、Client bridge、重连和清理逻辑,API Proxy 也无法退回只承接尚未迁移的业务方法。 + +Host 向 Client 的 Cordis 事件还有两种调用语义。普通通知只需要广播;Approval 与 Question 一类 Agent-scoped waterfall 必须允许 Client claim、调用 `next()` 委托、返回结果或拒绝,并在多 Client、断线和取消下保持一次 Host 调用的身份。 + +这些需求需要一个通用 transport 生命周期,但不能让 Gateway 理解 Session、Workspace、Approval 或 Question 的业务数据。 + +## 决定 + +API Gateway 拥有 Remote transport、stream 生命周期和 Remote Event 协调;Session Controller 与 Workspace Controller 拥有各自的 Host API、wire 类型和 Client 领域 adapter;Client Runtime 只装配并消费这些对象,不再实现另一套 carrier 状态机。 + +当前所有权如下: + +```text +[client/connection] +|-- Host description +|-- Connection generation +`-- unary RPC transport + +[api/gateway/client] +|-- RemoteStream +|-- RemoteSnapshotStream +|-- RemoteJournalStream +`-- ctx.remote.$on + $events pump + +[api/session-controller] +|-- ctx.remote.session unary commands +|-- session.control snapshot stream +|-- session.page + session.follow journal +`-- Session Client adapters + +[api/workspace-controller] +|-- ctx.remote.workspace unary commands +|-- workspace.follow snapshot stream +`-- Workspace Client model and adapter + +[api/remotes] +`-- application Remote Event allowlist and Host Cordis source + +[client/runtime] +`-- compose Session and Workspace domain state for consumers +``` + +API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 下行事件 carrier。`/api/events.host`、`HostFrame`、`stream/error`、`ServerRequest` 及其 WebSocket/SSE 分支不参与这条数据链路。 + +### Connection generation 与物理连接 + +浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。 + +首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。 + +进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。 + +Gateway 内部 `$events` logical stream 是 `ConnectionHandle` 唯一的 generation source。它不依赖是否已有业务 `$on` 订阅,因此连接健康状态不会随 UI listener 数量变化。 + +Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送带 `clientId` 的 `{ type: 'ready' }`,该帧证明当前 generation 已经能够接收增量。 + +`ConnectionController` 并行等待 `$events` ready 与 `host.describe`。两者都完成后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 + +`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回 `hostDescription`,退避后重新建立 `$events` 与 `host.describe`。 + +Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 + +插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。 + +### 通用 Remote stream 模型 + +Gateway Client 提供三个不依赖 React、只允许一个 consumer 的生命周期对象: + +```text +RemoteStream +|-- RemoteSnapshotStream +`-- RemoteJournalStream +``` + +领域 Controller 通过组合或薄 adapter 使用它们;Session 与 Workspace 不继承一个知道领域帧的共同 Controller 基类。 + +#### `RemoteStream` + +`ctx.remote.$stream(options)` 返回 `RemoteStream`,负责一个 logical stream 跨物理 generation 的重开、取消和 dispose。 + +每个 item 携带单调 generation、该 generation 的 `AbortSignal` 与 `accept()`。领域 consumer 只有在验证 opening cursor 或 baseline 后才调用 `accept()`。 + +只有 `RemoteStreamCarrierError` 可触发重试。Host 仍可用时允许一次独立重开;否则等待新的 Connection generation。业务错误、协议错误和 opening 失败直接终止。 + +`restart()` 只淘汰当前物理 generation,保留 logical stream;`dispose()` 永久结束 logical stream、pending retry 与 iterator,并等待 quiescence。 + +`RemoteStream` 不理解 baseline、delta、page、cursor、seq 或任何领域 frame。 + +#### `RemoteSnapshotStream` + +`RemoteSnapshotStream` 要求每个 generation 恰好以一份完整 snapshot 开始,之后只能出现 delta。 + +update 早于 snapshot 或同 generation 出现第二份 snapshot 都是 terminal protocol error。 + +snapshot 成功应用后才接受该 generation。carrier 重连期间保留上一份已发布状态,新 generation 的 snapshot 一次性替换旧镜像。 + +领域 adapter 提供 frame 判别、snapshot replacement、delta reducer、carrier 状态和 terminal failure sink;通用层不解析 Session 或 Workspace 字段。 + +Session control 与 Workspace state 各使用一个独立的 `RemoteSnapshotStream`。 + +#### `RemoteJournalStream` + +`RemoteJournalStream` 组合一个 live follow 与同 namespace 的 page 方法,适用于有稳定顺序、可分页历史和 live tail 的 append-only journal。 + +首次打开先建立 follow 并取得 opening cursor,再读取 initial page。page 请求期间产生的 live entries 已进入 follow 队列,因此不会落在“先读历史、后订阅”的竞态窗口中。 + +通用层按 cursor 去除 page 与 queued entries 的重叠,验证连续性,并在 page 覆盖 opening cursor 后发布一份完整 window。 + +连续 live entry 发布 `append`,更早的历史页发布 `prepend`。重连、cursor 跳跃或无法证明连续性时触发 tail page repair。 + +repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼成连续窗口后只发布一次 `replace`,不会把半修复状态暴露给消费者。 + +若 page 请求随物理 carrier generation 一起取消,journal 等待下一 generation 的 opening cursor,再以新 cursor 重读 page;该取消不会作为 terminal page failure 泄漏给领域对象。 + +`RemoteJournalStream` 拥有 opening cursor、resume cursor、分页、重连 catch-up、重叠去重和 gap repair。领域 Session 对象不复制这些状态机。 + +### Session Controller + +`packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。 + +它拥有 Session list、search、create、models、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。 + +包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。 + +其他 Host Remote namespace 通过 `ctx.sessionController.inspect()` 或 `resolveAgent()` 复用同一身份规则,不保留第二份 Session resolver。 + +#### 激活策略 + +Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类型触发通用 Typert Session lookup。 + +每个方法显式选择冷检查、live-only 查找或允许 resume 的解析方式: + +| 操作 | 无 live Agent 时的数据来源或结果 | 激活规则 | +|---|---|---| +| `session.list`、`search` | persistence、投影缓存或冷日志 | 永不恢复 Agent | +| `session.page(address)` | attached Session 或 persistence 日志 | 永不恢复 Agent | +| `session.follow(address)` | 冷读当前 cursor,等待将来的 append | 建联和等待都不恢复 Agent | +| `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent | +| `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | +| `session.updateQueue`、`cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent | +| `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 | +| `create` 与 fork 目标 | 新 Session/Agent | 用户命令提供创建授权 | + +读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。 + +#### Session 日志 + +`session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。 + +tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只携带历史 entries。Client 以 projection watermark 合并 page 与后续 live control 更新。 + +普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。 + +`session.follow` 在检查 attached Session 或 persistence 前先安装 `session/event` 与 `session/created` listener,再读取当前 cursor。 + +首次 follow 返回 `{ type: 'opened', cursor }`。带 `afterSeq` 的 generation 先从权威日志重放缺失后缀,再按 seq 排出读取期间缓存的 commit。 + +冷 Session 可以立即打开历史并保持 follow 等待。只有另一条显式命令恢复 Agent 后,后续事件才会出现。 + +Client 的 `SessionEventStream` 继承 `RemoteJournalStream`,只提供 `session.follow`、`session.page`、Session seq 算法与 repair request。通用层先取得 opening cursor `C`,再调用 `session.page({ throughSeq: C })`;读取期间收到的 `C + 1...` entries 留在 follow 队列中,page 精确覆盖至 `C` 后才按连续 seq 合并并发布。 + +```text +ctx.remote.session.follow(address, afterSeq?) --------| + |[]> SessionEventStream +ctx.remote.session.page(address, throughSeq, pageArgs) -| |-- replace(window) + |-- prepend(history) + `-- append(live entry) +``` + +每个 Client Session 只持有一个当前 `events: SessionEventStream | undefined`。只读 `SessionEventSource` 把已物化 event window 交给 Conversation consumer。 + +Session 的 `openGeneration` 只阻止被 resync、地址替换或 dispose 淘汰的异步结果写回;它不参与 transport retry。 + +initial page、repair page 或 follow 的 terminal failure 进入当前 Session 的 `openError`。旧业务 epoch 或旧 stream 的失败不能覆盖新状态。 + +#### Session live control + +`session.control()` 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。 + +每个 generation 先发完整 baseline,再发 queue、jobs 与 projection 增量帧。baseline 读取 attached Agent 和进程内 registry,不恢复冷 Agent。 + +queue 与 jobs 使用完整 replacement 值并按 last-wins 应用。Agent attach、detach、Session disposal 与 owner disposal 都能用空值或新 baseline 清除陈旧镜像。 + +原始 `approval/request` 与 `user-questions/request` 是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 `next()`,原 Cordis waterfall 继续到后续 Host listener。Session control 不保存或重放这些请求。 + +projection baseline 与 tail page 的日志切点独立产生,Client 总是保留较高 seq 的值。订阅 live projection 不会为取得值而启动 Agent。 + +Session added、removed、activity、running status 与无 turn 位置的 Agent error 不进入 stateful control stream;它们是可由列表 baseline 修复或无需重放的 `ctx.remote.$on` 通知。 + +Session 列表的 `updatedAt` 取 `max(header.createdAt, sessionListMetadata.lastPromptAt)`。`lastPromptAt` 只由用户来源的 `user/message` 更新,可从冷 projection 恢复,不依赖浏览器是否正在跟随该 Session。 + +### Workspace Controller + +`packages/api/workspace-controller` 提供 Host `ctx.workspaceController` 与生成的 `ctx.remote.workspace` namespace。 + +它拥有 create、rename、delete、insertBefore、insertSessionBefore、archiveSession 与 `follow`。Workspace registry 仍是持久事实来源,Controller 负责 Remote 命令、投影和错误映射。 + +`WorkspaceFeed` 同步观察 storage `domain/changed`,并为每个 follow generation 先发送完整 baseline,再发送 `upsert`、`remove`、`order` 与 `archived` 增量。 + +完整 `order` frame 是 Workspace 排序的权威值。它避免 Client 根据 upsert 到达顺序猜测展示顺序,也能在重连 baseline 后收敛。 + +`createWorkspaceStateStream()` 把 `workspace.follow` 装配为 `RemoteSnapshotStream`。Client Runtime 只负责启动和持有该 stream。 + +`ClientWorkspaceModel` 位于 Workspace Controller 的 Client 面,拥有 baseline/increment 解析、已物化列表、归档集合、命令结果回显及 unary 与 stream 到达竞态的合并规则。 + +成功的 unary 命令可以立即更新本地模型;后到的 stream commit 仍以 Host projection 与完整 order 校正状态。已删除 Workspace 的 id 被记录,延迟结果不能把它重新插回列表。 + +```text +ctx.remote.workspace.follow() -|[]> RemoteSnapshotStream + |-- replace(baseline) + |-- upsert/remove(view) + |-- replace(order) + `-- replace(archived ids) +``` + +Workspace Remote 方法、状态 feed 和 Client 数据模型均不经过 API Proxy,也不依赖 `host/workspace-*` 通知。 + +### Remote Event + +Remote Event 复用 owner 包的 Cordis `Events` 声明。Host 原事件是唯一业务签名,Client `ctx.remote.$on(event, listener)` 从同一声明推导参数、waterfall 结果与 `next()`。 + +`packages/api/remotes` 的 allowlist 是应用选择的唯一来源。每项显式标注 `emit` 或 `waterfall`,该 mode 同时决定 Host 监听方式、Client 合法键集和 wire frame 类型。 + +系统不声明 `RemoteInvocationMap`,不要求 Client 再写一份 `@Remote`,也不以最后一个运行时参数是否为函数来猜测调用模式。 + +Remote Event 下行帧是显式 discriminated union: + +```text +ready { type, clientId } +emit { type, event, args } +waterfall { type, event, eventId, agentId, request } +cancel { type, eventId } +``` + +WebSocket JSON 与进程内 carrier 的入口都从 `unknown` 开始按 `type` 和精确字段验证;验证完成后的分发只接收 typed union。TypeScript 静态类型不替代 wire 校验。 + +普通 `emit` 参数必须是无损 JSON。Client 在每个 Remote 实例私有的 Cordis key 上调用 `parallel()`,保留注册顺序、调用方 fiber 所有权和 listener 错误隔离。 + +私有 key 防止 Host 事件与 Client 本地同名 Cordis 事件互相触发。Client Remote 不维护自己的 subscription registry 或手写 listener chain。 + +可返回的 waterfall 当前只支持 Agent scope。事件签名必须是一个含直接 `agent` 字段的 request,加一个返回同类型结果的 `next()`,整体返回 Promise。 + +Host 只投影 request 一级的 `agent` 与 `signal`:`agent` 变为 frame 的一级 `agentId`,`signal` 成为 delivery lifetime,其余字段必须整体为无损 JSON。 + +Client 用 `agentId` 同步解析或物化 Agent Context,把当前 delivery signal 放回 request 的直接 `signal` 字段,再在目标 Context 的私有 key 上调用 Cordis `waterfall()`。Session-backed adapter 在首个成功 Session 列表 baseline 到达前允许 transport 先物化 scope;baseline 到达后由列表生命周期接管 scope 存活判断。 + +系统不扫描任意深度对象,不传 path array 或 placeholder,不 deep clone/restore Context 和 AbortSignal,也不等待未来出现的 Agent Context。 + +Client adapter 未注册、resolver 未返回 Context 或解析抛错时,本 Client 立即返回 `next`。它不订阅 registry、不做 resolve 后竞态复查,也不为一次 delivery 创建临时 Fiber。 + +Gateway Host 为每个未完成 waterfall 保存 `eventId`、Host continuation 与已投递 Client generation。新 Client generation 会收到同一 pending event 的重放。 + +每个 generation 的队列保证一次投递,因此 Client 不保存 `seen` 集合。`clientId + eventId` 绑定结果与当前 generation,旧连接的回包不能完成新连接上的 delivery。 + +多 Client 同时接收 waterfall 时,第一个 result 或 rejection 完成 Host 调用,并向其余 Client 发送 `cancel`。只有所有已投递 Client 都返回 `next` 时,Gateway 才继续原 Cordis chain。 + +Host caller signal 取消、Agent Context 释放、Client generation 结束和 losing-client cancellation 都会终止对应的等待。 + +Client 通过现有 HTTP unary RPC `$events/result` 回送 `next`、result 或 rejection;下行事件仍复用 Remote WebSocket mux,不为应答建立 duplex WebSocket。 + +Gateway 只验证 waterfall 返回值能无损表示为 JSON,不解释业务字段。Question 回答的 option 归属等语义由请求方或 UI 领域承担,transport 不重复校验。 + +`UserQuestionService` 在请求期间观察到调用方 `AbortSignal` 已取消、且 provider 抛出普通错误时,将其归一为 `UserQuestionError` 的 `ASK_ABORTED`,并把原错误保留为 `cause`;provider 已给出的领域错误保持不变。 + +`$events/result` 失败会令当前 Connection generation 失败。Host 随 generation 撤销该 Client 的 delivery,pending event 在下一 generation 重放,Client 不维护第二套结果重试队列。 + +普通 `$on` 通知在断线后不重放。凡正确性依赖恢复的数据必须有 query、cursor 或 opening baseline,不能依赖 Remote Event 恰好送达。 + +Client listener 晚于事件到达才注册时不补送;HMR 也没有专用补投语义。 + +### API Proxy 的剩余边界 + +Session Controller 与 Workspace Controller 直接提供生成 Remote namespace;API Remotes 与 API Gateway 直接提供 Host-to-Client 事件。 + +Client Connection 只维护 Host generation、description 与通用 RPC,不解析领域 frame。 + +Client Runtime 只接收 Controller adapter 产出的领域变更,不识别 `HostFrame`、`session/subscribed`、`session/event` mux frame 或 `host/workspace-*` frame。 + +API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace、Remote Event 或 Connection generation 的依赖。 + +## 备选方案 + +**建立任意 Session stream 时自动恢复 Agent。** 这会让查看历史、读取 title、重连标签页或观察后台状态产生执行副作用,也会让多个浏览器触发重复恢复;冷日志和投影已有 persistence 来源。 + +**只允许 live Agent 使用 `session.follow`。** 这会迫使 transcript 首屏恢复 Agent,或重新引入 unary history 与 live subscription 之间的竞态;按 identity 先 follow 再冷读能同时覆盖历史和未来的显式激活。 + +**把 Session transport 与 Session commands 拆成两个公开包。** 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。 + +**把 queue、jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。 + +**让每个领域 Controller 继承一个 page/follow/retry 基类。** Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。 + +**给 Remote Event 新建一份 Client invocation 声明。** 第二张 map 或 Client `@Remote` 会复制 owner Cordis 事件签名并形成漂移点;从同一 `Events` 声明推导 `$on` listener 和结果类型可以构造性地保持一致。 + +**把 Agent scope 做成任意深度对象投影。** 递归扫描 Context 与 AbortSignal 需要 path、placeholder、clone 和 restore 协议,并把偶然对象结构升级成 wire 约定;一级 `agent` 与 `signal` 足以覆盖当前 waterfall。 + +**等待 Client Agent Context 或 adapter 后再分发。** registry waiter、竞态复查和临时 delivery Fiber 会为一个可同步解析或物化目标的 Client 增加额外生命周期;resolver 当下不能提供目标时立即 `next` 保持 Cordis waterfall 语义。 + +**给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。 + +**继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。 + +**从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。 + +## 验证 + +Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 + +Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。 + +`RemoteStream` 测试固定单 consumer、opening acceptance 后清零 retry、`restart()` 只替换 generation、terminal error 不重试和 dispose quiescence。 + +`RemoteSnapshotStream` 测试固定每 generation 恰好一份 opening snapshot、update-before-snapshot 拒绝、重复 snapshot 拒绝和重连 replacement。 + +`RemoteJournalStream` 测试固定 follow-before-page、opening overlap 去重、连续 append、历史 prepend、重连 catch-up、gap repair 与一次性 replacement。 + +Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。 + +Session control 测试固定 baseline-first、冷 Session 不恢复、attach/detach 清理、queue 与 jobs replacement,以及 projection watermark。 + +Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。 + +Workspace Host 测试固定 baseline-first、upsert/remove、权威 order、archived set 和 follower disposal。 + +Workspace Client 测试固定 snapshot replacement、unary/stream 竞态、删除不复活、稳定排序和 terminal failure。 + +Remote Event 类型测试拒绝未选择事件、非 void 的 unscoped 事件、非 Agent-scoped waterfall 和签名不匹配的 mode。 + +Remote Event Host 测试固定 listener-before-ready、payload 校验、pending replay、多 Client first-result、all-next delegation、rejection、Host cancellation、Context release 和 losing-client cancel。 + +Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent Context 解析、`next`、result、rejection、cancel、旧 generation 回包拒绝和 `$events/result` 失败导致 generation 结束;User Question 测试固定进行中 signal 取消的错误归一化及 cause 保留。 + +缺失 source、重复 source、撤回 source、非 ready 首项、未知 discriminant、额外字段与非 JSON 值都在各自 wire 入口响亮失败。 + +静态检查固定 API Proxy 不再导出 Session/Workspace Host frame carrier,Client Runtime 不再包含对应 bridge。 + +## 后果 + +浏览器可以在 Agent 停止时读取并跟随持久 Session。观察不隐式恢复执行,只有明确获得授权的 Session 命令按各自约定创建或恢复 Agent。 + +持久日志用 seq 与 page 修复缺失后缀;Session control 和 Workspace state 用 opening snapshot 收敛;普通 Remote Event 不承诺重放。恢复语义由数据类型决定,不再互相模拟。 + +Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。 + +Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。 + +通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。 + +Remote waterfall 保留多 Client 首个 claim、全体 `next` 后继续 Host chain、断线重放 pending 和端到端取消;代价是当前协议只支持一级 Agent scope 与无损 JSON 请求/结果。 + +本决定扩展[Remote 事件投递](2026-08-10-remote-event-delivery.zh.md)的 allowlist 与单一 Cordis 签名设计:普通通知继续使用 `emit`,Agent-scoped async waterfall 使用同一 `ctx.remote.$on` 面和显式 `waterfall` mode;不建立第二套 invocation map。 + +本决定接管[简单一元 API Proxy 迁移](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md)中保留的 Session、Workspace 与 Host event carrier,并保留[后台任务展示](../feature/2026-08-08-web-background-job-display.zh.md)所要求的完整 jobs snapshot、进程内生命周期和“观察不恢复 Agent”语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml new file mode 100644 index 0000000000..f616ad829c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa +2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md new file mode 100644 index 0000000000..8e5521ff19 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md @@ -0,0 +1,461 @@ +# Agent Note: Client Session, Conversation, and UI ownership layers + +Status: implemented + +English | [中文](2026-08-20-client-session-conversation-ownership.zh.md) + +## Problem + +The Web Client once placed Session and Workspace objects, event windows, Conversation assembly, React hooks, the Slot registry, and the Store engine in one general Runtime. Protocol state, business projections, React bindings, and page presentation shared one dependency hub, so a change in any layer could spread across the entire frontend. + +Session snapshots could also accumulate data they did not own, including event arrays, Conversation Views, Chat Nodes, and pending interactions. Ordinary consumers then had to understand event replay and concrete views, while adding a Conversation target could require changes to Session, Runtime, and the renderer. + +Without an explicit interface between React and Session lifetimes, binding release, Hook source replacement, and Slot store cleanup became dedicated callback protocols. Approval and Question both affect sidebar state and composer takeover; independently maintained state could make those surfaces select different pending requests. + +The Client needs one-way dependencies between data owners, React adapters, generic rendering machinery, and concrete views while preserving application behavior. + +## Decision + +The Client uses the layering “Controller and domain object → UI adapter → renderer → Slot component.” Controllers and domain objects publish React-free observable sources; their `ui-*` packages declare standard props and register sources; `ui-renderer` creates selector hooks at Slot binding points; components read data and actions only from Slot props. + +```text +[Remote / Controller / domain object] + | + | bare observable source + v + [ui-* adapter] + | + | standard source registration + v + [ui-renderer] + | + | selector hook binding + v + [Slot component] +``` + +Client Session and Workspace objects belong to `api/session-controller/client` and `api/workspace-controller/client`, respectively. Target-neutral Conversation data structures and assembly belong to `client/ui-conversation`; Chat and Trajectory belong to `client/ui-chat` and `client/ui-trajectory`, respectively. + +The React adapters for Session and Workspace belong to `client/ui-session` and `client/ui-workspace`. The Store engine belongs to `client/store`; the Slot registry, scope materialization, and observable-to-hook binding belong to `client/ui-renderer`. + +The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines Session history, Remote streams, pagination cursors, and reconnect continuity; this note starts from the Client objects and sources published by Controllers. + +## Layering principles + +### Controllers are React-free logic owners + +A Controller may be installed as a Cordis service, but it does not own React Contexts, React hooks, Slot props, or components. A Controller snapshot contains only facts that it owns, and its commands change only Host or domain-object state. + +The UI layer may read multiple Controllers for one navigation decision, but it does not write the combined result back into any Controller snapshot. A UI adapter does not duplicate a Controller command's business implementation. + +### UI adapters own React integration + +Each standard hook belongs to the `ui-*` package closest to its data semantics. + +| Hook | Owner | Source | +| --- | --- | --- | +| `useSessions` | `client/ui-session` | Session Controller global list | +| `useSession` | `client/ui-session` | Current Session snapshot | +| `useProjection` | `client/ui-session` | Current Session keyed projection | +| `useSessionPendingInteraction` | `client/ui-session` | Aggregated pending domains | +| `useWorkspaces` | `client/ui-workspace` | Workspace Controller list | +| `useConversation` | `client/ui-conversation` | Conversation binding snapshot | +| `useChat` | `client/ui-chat` | `chat` target source | +| `useTrajectory` | `client/ui-trajectory` | `trajectory` target source | + +`ui-renderer` implements only generic binding. It does not import Session, Workspace, Conversation, Chat, or Trajectory business types or values. + +### Slot scopes and standard props are separate + +`ui-slots` declares root, session, and session-maybe scopes plus declaration-merge-extensible standard prop types. It does not decide which hooks each scope installs. + +`ui-renderer` implements generic scope adapters and source materialization. `ui-session` installs the Session scope and supplies its built-in sources; other domain packages register only their own sources and the Slot entries that consume them. + +Adding a target does not add a branch to the renderer or Session Controller. The data owner handles state identity, updates, errors, and release; the UI adapter owns the hook; the presentation owner owns target-specific projections and interaction state. + +## Package ownership + +| Package | Owns | Explicitly does not own | +| --- | --- | --- | +| `api/session-controller/client` | Session objects, list, selection, commands, projections, queue, event windows, and Agent Contexts | Conversation targets, React, Slots, Workspace | +| `api/workspace-controller/client` | Workspace objects, ordering, archive state, commands, and snapshots | React, Session navigation policy, directory UI | +| `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results | +| `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data | +| `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots | +| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory | +| `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots | +| `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election | +| `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election | +| `client/store` | React-free Store contract and implementation | Domain objects, React hooks, Slot lifetimes | +| `client/ui-renderer` | SlotRegistry, scope binding, selector hooks, outlets, and React root | Session, Workspace, and Conversation business logic | + +## Overall data flow + +Session data reaches the UI through this path: + +```text +[ctx.remote.session] + | + v +[api/session-controller/client] + |-- SessionListState --------------------------> [ui-session] -> useSessions + |-- SessionSnapshot ----------------------------> [ui-session] -> useSession + |-- ProjectionValueSource ----------------------> [ui-session] -> useProjection + `-- per-Session SessionEventSource + | + v + [client/ui-conversation] + | + | assemble + v + ConversationSnapshot ----------------> useConversation + | + |---------+----------| + v v + [ui-chat] [ui-trajectory] + | | + useChat useTrajectory +``` + +Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. For cross-domain navigation, `ui-workspace` temporarily reads the Session Controller and issues a selection or command. + +Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.pendingInteractions` then supplies that same object to Session navigation state and Conversation composer selection. + +## Session Controller Client + +### Scope of SessionSnapshot + +`SessionSnapshot` represents control and lifecycle facts belonging to a Session. It may contain identity, running, removed, blank, subagent address, open phase, history phase, prompt error, agent error, and queue state. + +It does not contain: + +- a raw event array; +- Conversation Views; +- Chat Nodes; +- Trajectory rows; +- pending Approval or Question objects; +- presentation state that requires callers to traverse events. + +Whether a field derives from an event, control frame, or local command does not automatically determine its owner; consumption semantics determine ownership. `composerPhase` depends on both Session lifecycle and Conversation target activity, so `ui-conversation` composes it instead of placing it in `SessionSnapshot`. + +### Three read faces + +The Session Controller exposes three distinct read faces: + +1. The global Session list and current-selection source, used by navigation and `useSessions`. +2. A logical binding for each Session containing `sessionId`, a `SessionSnapshot` source, commands, and projection sources. +3. A Conversation-facing `SessionEventSource` used only by the Conversation assembly core. + +Ordinary UI components do not read `SessionEventSource` directly. `ui-session` does not read private event windows, and the `ui-conversation` core receives neither React bindings nor Slot APIs. + +### SessionEventSource + +`SessionEventSource` exposes a materialized event window, not a transport. + +The window carries ordered `entries`, `hasMore`, a monotonic `revision`, and a `replace | prepend | append` change description. Append links an immutable segment in constant time; a consumer that needs the complete `entries` array materializes and caches it for that snapshot. + +Initial open, reconnect, gap repair, and updates whose continuity cannot be proven publish `replace`; history pagination publishes `prepend`; a continuous live event publishes `append`. The Conversation core selects incremental update or complete rebuild from the revision and change. + +`MutableSessionEventSource` is the Session Controller's internal write face. Consumers depend only on the read-only `SessionEventSource`. + +### Session binding lifecycle + +Each Session binding owns a Cordis Context and Fiber. The Session Controller creates and releases the binding. + +Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Releasing a binding cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol. + +This cleanup does not require the Session Controller to know the roster of upper-layer consumers. + +## UI Session + +### Service responsibilities + +`client/ui-session` is the sole Session adapter between the Session Controller and the React/Slot system. It provides `ctx.uiSession` and: + +- observes the Session list, current selection, and per-Session bindings; +- installs the session and session-maybe scope adapters; +- supplies `SessionProvider` rendering semantics; +- supplies built-in Session snapshot, projection, and sessionId sources; +- accepts Session-scoped source contributions from other domain packages; +- aggregates pending interactions registered by business packages. + +It does not own Session transport, event folding, Conversation targets, or concrete business results. + +### Standard source registration + +A domain package calls `ctx.uiSession.provide()` to register a bare source. The descriptor statically declares its hook, keyed-hook, and prop rosters; `resolve(binding)` returns exactly those values for one Session binding. For example, `ui-conversation` registers each binding's snapshot as the `conversation` hook source. + +The renderer converts an ordinary source into `use`. Open key spaces such as projections use a keyed-hook resolver, while stable values use props. + +The runtime rejects undeclared, missing, or duplicate standard props. `ui-session` materializes its own built-ins through the same mechanism, so the renderer has no Session-specific name branches. + +### Scope binding + +session and session-maybe use the same adapter with different binding semantics: + +- a strict session scope refuses to render without a current binding; +- session-maybe uses a stable absent binding to preserve hook call order; +- changing the current Session rebuilds the strict Session subtree under the `sessionId` key; +- root and session-maybe entries may remain mounted across Session changes. + +Each real materialized binding retains the Controller binding's Context. `ui-session` removes the cache entry and withdraws the current binding through `binding.ctx.effect()`. + +Changing the contribution roster rematerializes existing bindings and publishes a new source set. Source identity remains stable within one binding lifetime, as required by `useSyncExternalStore` caching. + +### SessionProvider + +`SessionProvider` is a standard seat derived by `PropsRenderSlots` from a session-scoped child declaration, not a React Context imported directly by business components. + +It accepts ordinary `ReactNode` children rather than a `(sessionId) => ReactNode` render function; callers wrap `renderSlot('details', {})` directly. + +Session identity comes from the scope binding and standard `sessionId` prop. The Provider handles only the absent branch and subtree isolation by Session identity; components do not obtain Session data through a Provider callback. + +### Pending interactions + +Business packages extend `SessionPendingInteractionMap` through declaration merging. Every pending object carries at least a stable `key`, domain `kind`, and `sessionId`; `ui-session` does not import concrete Approval or Question types. + +A business plugin calls `registerPendingInteraction(precedence)` in `apply()` to create a stable registration for its pending domain. The returned per-request publication function publishes one exact object together with its waterfall-delegation callback and returns an idempotent disposer for that object. Plugin teardown removes all published objects before invoking and awaiting their delegation callbacks, so active Host requests cannot remain suspended after their Client answerer unloads. + +Concurrent objects with the same key are rejected; replacement requests use a new key. One Session may hold multiple domains or requests at once. + +`ui-session` selects each Session's effective object using domain precedence. Higher precedence wins; at equal precedence, the later valid object in traversal order wins. + +The aggregate is published as `pendingInteractions: ObservableSnapshot>`; `useSessionPendingInteraction` is its React read face. + +Session navigation state and composer takeover read the same effective object. They do not maintain separate status maps or takeover rosters. + +## Workspace Controller and UI Workspace + +### Scope of WorkspaceSnapshot + +`WorkspaceSnapshot` contains only Host-authoritative data owned by the Workspace Controller, including Workspace rows, order, archive set, follow phase, and errors. A Workspace row's `sessionIds` is an association field, not a copy of Session objects in the Workspace snapshot. + +These combined facts do not enter `WorkspaceSnapshot`: + +- whether the Workspace and Session baselines are both ready; +- the most recent Workspace derived from Session update times; +- whether the current Session is cleared because it was archived; +- which blank Session New Session should reuse; +- which Session initial startup should select. + +### UI Workspace composition responsibilities + +`client/ui-workspace` registers the Workspace list source as the root standard source `workspaces`, from which the renderer provides `useWorkspaces`. + +Initial selection, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI navigation policy. That policy may read both `ctx.workspaces` and `ctx.sessions` at decision time, but it issues only Controller commands and selection actions and does not publish a combined snapshot. + +Directory pickers, directory browsing, and `openPath` are separate directory capabilities and do not enter the Workspace Controller. + +## UI Conversation + +### Assembly core + +`client/ui-conversation` contains both the React-free Conversation assembly core and the React adapter for the same domain. + +The core owns `ConversationSnapshot`, the Definition registry, the View registry, the event assembler, the location index, per-Session bindings, target sources, and target activity. + +The core obtains `SessionEventSource` from a Session binding. Append and prepend changes with continuous revisions use incremental assembly; replace changes or revision gaps rebuild from the complete window. + +Definition or View roster changes rebuild only the Conversation binding; they do not rebuild a Session or reopen a Remote stream. The core does not import React and can test event folding, incremental updates, and registry lifetimes independently. + +`ConversationSnapshot` does not copy `SessionSnapshot` or expose raw events. It publishes only the target-neutral View roster, target activity, and target-source lookup. + +`useSession` and `useConversation` come from separate sources and are not guaranteed to publish atomically in one React commit. Components that read both compute purely from their current snapshots and do not treat notification order as business causality. + +### Definition and View registries + +`UiConversation.events` is the sole registry for event Definitions, and `UiConversation.views` is the sole registry for target snapshot builders. + +The registries reject duplicate keys, preserve registration order, and return idempotent disposers. Existing Conversation bindings rebuild from their current event windows when a roster changes; changes in one synchronous registration turn are coalesced into one microtask rebuild. + +A target package extends snapshot and location-data maps through declaration merging, then registers its Definitions, builder, and View. Registrations follow Cordis effect disposal. + +`ui-conversation` does not import concrete target packages. + +### Conversation React adapter + +The React adapter registers each Conversation binding snapshot as the Session standard source `conversation`, from which the renderer provides `useConversation`. + +The package also owns the shell, input, composer chain, queue UI, drafts, View navigation, and phase composition. The core reads no React Context, Slot props, or component state. + +View selection order is a valid persisted selection, registered `chat`, then no View. An invalid selection does not overwrite the persisted value, and the system does not fall back to the first registered View. + +Without `ui-chat`, the shell can still activate and mount but does not implicitly select Trajectory or another target. + +The shell phase is a pure composition of Session lifecycle and Conversation target activity. An active Session or any target reporting visible content produces active; a failed first prompt remains engaging. + +### Input and composer + +The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads the current Session's effective object through `useSessionPendingInteraction` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`. + +A selector is a pure function of owner currency. Its non-null result reaches the selected component as `matched`. A stable composer entry and the default composer remain mounted together, while the chain selects one effective presentation. + +Draft and input state belong to Conversation UI and do not enter the Session snapshot. Queue commands use a Session-scoped service for addressing and do not write queue UI into the Conversation core. + +## Chat and Trajectory targets + +### Chat owner + +`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache. + +It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`. + +Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation. + +The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input. + +### Trajectory owner + +`client/ui-trajectory` registers `trajectory` through the same target protocol. It owns event-record projection, timelines, virtual rows, selection, and the inspection view, and exposes `useTrajectory` through a standard source. + +Session lifecycle reads `useSession`, while Trajectory data reads `useTrajectory`. Trajectory does not obtain its own data through a Session or Chat snapshot. + +Other targets use the same registration flow without modifying the renderer, Session Controller, or ui-session. + +## Approval and Question + +### Stable registration + +Approval and Question plugin installation separates stable registrations from per-request handling. `apply()` registers locale data, calls `registerPendingInteraction()` once for its pending domain, and registers one stable entry in `conversation.composer`. + +The stable Approval entry also declares its detail child Slot. Concurrent requests and Session count do not add composer entries or redeclare Slots, and every registration follows plugin-fiber disposal. + +### One waterfall request + +A Remote Event listener resolves the Session from its own Agent Context. Without a Session scope it calls `next()` to continue the waterfall; with a Session scope it creates a `PendingApproval` or `PendingQuestion`. + +The listener publishes the object through the registered domain publication function, waits for user completion, cancellation, or request-signal abortion, and removes the exact object in `finally`. + +One request does not register a Slot, create another lifecycle effect, or mutate the Session snapshot. + +Approval exposes allow and reject; Question exposes answer and cancel. User cancellation of a Question returns `ASK_CANCELLED`; interruption of a pending request by `AbortSignal` returns `UserQuestionError(ASK_ABORTED)` rather than leaking the carrier's `AbortError` or an ordinary `Error`. + +The Gateway requires only that Remote Event arguments and results are valid JSON transport values. It does not duplicate domain validation of Question options. + +### One pending projection + +The Sidebar and composer consume the same `pendingInteractions` snapshot. Navigation displays approval, plan-review, or question state from the effective object's `kind`; each composer entry selects its own panel by object identity. + +The same request identity drives both UI surfaces. A request that replaces another request of the same type uses a new key, so selectors and subscribers observe the identity change. + +`ui-session` implements only cross-domain precedence and does not interpret Approval or Question fields. + +## UI Renderer and Store + +### UI Renderer + +`client/ui-renderer` owns the `SlotRegistry` service and React renderer. It is responsible for: + +- `ctx.slots.register()`, `inject()`, `renderSlot()`, and declaration lifetimes; +- root, session, and session-maybe scope adapters; +- binding standard observable sources to selector hooks; +- Slot outlets, error isolation, root mount, and hydration; +- managing Slot store instance lifetimes by scope key. + +The renderer may know generic scope names and binding protocols but does not read domain services. Rendering Session scope without an installed adapter is an assembly error that fails immediately. + +### Store + +`client/store` is a plain React-free library owning `ObservableSnapshot`, `SnapshotStore`, `defineStore`, `createSnapshotStore`, and `shallowEqual`. + +`ui-slots` references the Store contract; `ui-renderer` manages Store instances and supplies `useStore`. + +Stores hold viewing and interaction state such as drafts, View selection, Chat selection, inspection requests, and panel size. Session, Workspace, Conversation, Remote streams, and connection generations do not enter Stores. + +### Registration and release order + +When one plugin provides both a source and a Slot entry, it registers the source first and the entry second. Reverse Cordis disposal then removes the entry before the source, so a mounted entry never briefly loses a required hook. + +Releasing a Session binding cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers. + +Every disposer is idempotent and depends on no implicit callback outside the Cordis lifecycle. + +## Composition and dependency direction + +The application bundle explicitly installs the required Controller, adapter, target, and renderer plugins. Each owner's `apply()` installs only its own service, listener, and contributions. + +Runtime consumption flows as `session-controller → ui-session → ui-conversation → target UI`, `workspace-controller → ui-workspace`, and `store → ui-slots → ui-renderer`; Approval and Question depend only on the pending-registration point exposed by `ui-session`. + +Arrows in this description represent runtime consumption and do not include type-only declaration-merge edges. Controllers do not depend back on UI adapters, the renderer does not depend back on domain packages, and the Conversation core does not depend on a concrete target. + +UI components do not receive `ctx`. Cross-package collaboration uses Cordis services, standard sources, or Slot registrations without introducing an aggregate facade. + +## Developer guidance + +### Choose the data owner first + +Before adding state, choose its sole owner from its consumption semantics: Host communication, commands, and entity lifecycle belong to an API Controller; data assembled from Session events but independent of a target belongs to the Conversation core; projections serving only one View belong to that target package; drafts, selections, and panel state belong to the UI package that owns the interaction. + +The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. A cross-domain decision reads multiple sources and immediately issues a command; it does not create a joined snapshot or cache another domain's object. + +These are signs of incorrect ownership: a Controller imports React; the renderer branches on business types; a component traverses Session events; a Store holds Session or Workspace entities; changing one target requires changing the Session Controller. + +### Add Session-scoped data + +1. Provide a React-free observable source in the domain owner. +2. Declaration-merge the standard prop type in the owning UI adapter. +3. Declare a fixed roster through `ctx.uiSession.provide()` and resolve its source from a Session binding. +4. Let the Slot component receive the generated hook through `PropsRuntime`; do not pass `ctx` to a component. +5. Attach each binding resource's cleanup to `binding.ctx.effect()` and leave registration cleanup to the plugin fiber. +6. Test missing values, duplicate names, roster replacement, Session changes, and binding disposal. + +Only open key spaces use keyed hooks. Finite stable sources use ordinary hooks, and immutable identifiers use props. Do not hard-code business names in the renderer to save one registration. + +### Add a Conversation target + +1. Extend the Conversation snapshot or location-data map in the target package. +2. Register the required event Definitions with `UiConversation.events`. +3. Register the snapshot builder, target id, View, and activity rule with `UiConversation.views`. +4. Expose the target's standard selector hook through `ctx.uiSession.provide()`. +5. Register the renderer, locale, and target-specific Slot entries in the same package. +6. Verify that unloading the target rebuilds only the Conversation binding without changing the Session, other targets, or Remote stream. + +A target must not use another target's snapshot as its data source. Optional collaboration uses a narrow port or Slot; when a target is absent, the shell remains bootable and does not guess a fallback. + +### Add a pending-interaction domain + +1. Define the Pending object and its completion, cancellation, and interruption semantics in the business package. +2. Add the object to `SessionPendingInteractionMap` through declaration merging. +3. Call `registerPendingInteraction()` once in `apply()` and register one stable composer entry. +4. Resolve the Session from the Agent Context in the Remote waterfall listener; call `next()` when the listener cannot handle the request. +5. When it can handle the request, create the Pending object, publish it through the publication function, await its result, and remove it in `finally`. +6. Test concurrent keys, precedence, user cancellation, transport abort, plugin disposal, and delegation without a Session. + +A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer both read one effective object from `useSessionPendingInteraction`. + +### Review checks + +- Every new source, registry contribution, listener, and cache has an explicit Cordis-fiber or Session-binding owner. +- Every public hook traces to one React-free source; no selector is forwarded through layers only to pass arguments. +- Every component obtains data and actions from standard props or the owning Slot's inject face. +- Every target has defined behavior when absent, dynamically registered, and unloaded. +- Every cross-layer import advances in the one-way Controller, adapter, renderer, component direction. +- Each error is classified by the earliest owner that can explain its semantics; carrier errors do not leak directly as business errors. + +## Verification + +Tests owned by each layer pin Controller bindings and event sources, UI scopes and pending precedence, incremental Conversation assembly and View fallback, target projections, waterfall results, and renderer scope/Store lifetimes. Application-composition tests cover both the complete roster and startup without a concrete target; component tests do not replace object-layer, replay, and lifecycle tests. + +## Alternatives considered + +- **Keep a Runtime facade.** One entry point would retain the dependency hub and let new code bypass domain owners, so the system provides neither the facade nor a compatibility export. +- **Put all Client state in API Controllers.** Protocol objects would then own React, Views, and presentation policy, so Controllers retain only React-free domain state. +- **Let Controllers provide React hooks directly.** Non-React consumers could not reuse the same objects, and transport and renderer lifetimes would become interdependent. +- **Put Conversation in SessionSnapshot.** This would expand the Session API and force ordinary Session consumers to understand event folding and target rosters. +- **Let Chat and Trajectory replay Session events independently.** Ordering, locations, and registry rebuild would be duplicated, so the shared assembly core stays in `ui-conversation`. +- **Extract the Conversation core into another non-UI package.** The core and adapter currently evolve together and have no other non-UI package consumer; directory separation within one package keeps the core React-free. +- **Combine Workspace and Session into one snapshot.** This would create another cross-domain owner, so cross-domain logic remains an immediate decision in `ui-workspace`. +- **Build every standard hook into the renderer.** Generic infrastructure would need to know every domain, so standard-source registration keeps the renderer independent from business types. +- **Dynamically register a composer entry for every pending request.** This would redeclare child Slots and make concurrent requests compete through registration order, so stable entries are separate from request publication. +- **Write pending interactions into a Session projection.** An unanswered waterfall is not a committed durable Session fact; Remote Event replay restores it after refresh, so it remains in a business UI source. +- **Add a dedicated release callback to bindings.** This would duplicate the Cordis lifecycle; `binding.ctx.effect()` already attaches consumer cleanup to the same owner. +- **Pass the Session id from SessionProvider through a render function.** This would create another data-injection path; ordinary children and the standard `sessionId` prop retain one entry for scoped data. +- **Keep Store in the renderer.** The Store contract does not depend on React and is reused by objects and test infrastructure, so `client/store` keeps the engine separate from rendering lifetimes. + +## Consequences + +Session, Workspace, Conversation, and each concrete target own one authoritative state. Non-React consumers can reuse Controllers and the assembly core directly. A new Conversation target registers its Definition, builder, View, standard source, and Slot entries; a new pending-interaction domain declares its type, registers its domain, and provides one stable composer entry. + +The renderer and Session Controller gain no branch for a new business domain, while Session bindings and plugin fibers provide two explicit, composable release paths. The UI can observe independent Session and Conversation source publications, and consumers cannot depend on their notification order. + +Composition packages must explicitly load the required adapter and target plugins. The shell remains operational without a concrete target but neither creates nor guesses that target's View. More packages and explicit registrations add assembly work, while dependency direction, test scope, and failure ownership become locally identifiable. diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md new file mode 100644 index 0000000000..a007a42b3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -0,0 +1,461 @@ +# Agent Note: Client Session、Conversation 与 UI 所有权分层 + +Status: implemented + +[English](2026-08-20-client-session-conversation-ownership.md) | 中文 + +## 问题 + +Web Client 曾由一个通用 Runtime 同时承载 Session 与 Workspace 对象、事件窗口、Conversation 组装、React hooks、Slot 注册表和 Store 引擎。协议状态、业务投影、React 绑定和页面呈现共享同一个依赖汇点,任何一层的变化都可能扩大到完整前端。 + +Session 快照也容易混入事件数组、Conversation View、Chat Node 和待处理交互等并非 Session 自身拥有的数据。普通消费者由此需要理解事件重放与具体视图,新增一个 Conversation target 也可能要求修改 Session、Runtime 和 renderer。 + +React 生命周期与 Session 生命周期之间缺少明确接口时,binding 释放、Hook source 替换和 Slot store 清理会演变为互相回调的专用协议。Approval 与 Question 同时影响侧边栏状态和 composer takeover;若两处各自维护状态,它们还可能选择不同的待处理请求。 + +需要把数据 owner、React adapter、通用渲染机制和具体视图拆成单向依赖,同时保持既有应用行为。 + +## 决定 + +Client 采用“Controller 与领域对象 → UI adapter → renderer → Slot component”的分层。Controller 和领域对象发布不依赖 React 的 observable source;所属 `ui-*` package 声明标准 props 并注册 source;`ui-renderer` 在 Slot binding 点生成 selector hook;组件只从 Slot props 读取数据与操作。 + +```text +[Remote / Controller / domain object] + | + | bare observable source + v + [ui-* adapter] + | + | standard source registration + v + [ui-renderer] + | + | selector hook binding + v + [Slot component] +``` + +Session 与 Workspace 的 Client 对象分别归 `api/session-controller/client` 和 `api/workspace-controller/client`。Conversation 的 target-neutral 数据结构和组装归 `client/ui-conversation`,Chat 与 Trajectory 分别归 `client/ui-chat` 和 `client/ui-trajectory`。 + +Session 与 Workspace 的 React 适配分别归 `client/ui-session` 和 `client/ui-workspace`。Store engine 归 `client/store`,Slot registry、scope materialization 和 observable-to-hook 绑定归 `client/ui-renderer`。 + +系统不提供聚合式 `client/runtime` package,也不设置替代它的总控 facade。Session history、Remote stream、分页 cursor 和重连连续性由 [Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md) 定义;本 Note 从 Controller 发布的 Client 对象与 source 开始。 + +## 分层原则 + +### Controller 是无 React 的逻辑 owner + +Controller 可以作为 Cordis service 安装,但不拥有 React Context、React hook、Slot props 或组件。Controller snapshot 只包含自身拥有的事实,命令只改变 Host 或领域对象状态。 + +UI 层可以同时读取多个 Controller 做一次导航决定,但不得把组合结果写回任一 Controller snapshot。UI adapter 也不复制 Controller 命令的业务实现。 + +### UI adapter 拥有 React 接入 + +每个标准 hook 归最接近其数据语义的 `ui-*` package。 + +| Hook | Owner | Source | +| --- | --- | --- | +| `useSessions` | `client/ui-session` | Session Controller 全局列表 | +| `useSession` | `client/ui-session` | 当前 Session snapshot | +| `useProjection` | `client/ui-session` | 当前 Session keyed projection | +| `useSessionPendingInteraction` | `client/ui-session` | pending domain 聚合结果 | +| `useWorkspaces` | `client/ui-workspace` | Workspace Controller 列表 | +| `useConversation` | `client/ui-conversation` | Conversation binding snapshot | +| `useChat` | `client/ui-chat` | `chat` target source | +| `useTrajectory` | `client/ui-trajectory` | `trajectory` target source | + +`ui-renderer` 只实现通用绑定,不 import Session、Workspace、Conversation、Chat 或 Trajectory 的业务类型和值。 + +### Slot scope 与标准 props 分离 + +`ui-slots` 声明 root、session 和 session-maybe scope,以及可通过 declaration merge 扩展的标准 props 类型;它不决定每个 scope 安装哪些 hook。 + +`ui-renderer` 实现通用 scope adapter 与 source materialization。`ui-session` 安装 Session scope 并提供内建 source,其他领域 package 只注册自己的 source 和消费它的 Slot entry。 + +新增 target 不要求 renderer 或 Session Controller 增加分支。数据 owner 负责状态身份、更新、错误和释放;UI adapter 负责 hook;显示 owner 负责 target-specific projection 与交互状态。 + +## Package 所有权 + +| Package | 拥有内容 | 明确不拥有 | +| --- | --- | --- | +| `api/session-controller/client` | Session 对象、列表、选择、命令、projection、queue、事件窗口和 Agent Context | Conversation target、React、Slot、Workspace | +| `api/workspace-controller/client` | Workspace 对象、顺序、归档、命令和 snapshot | React、Session 导航策略、目录 UI | +| `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 | +| `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 | +| `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot | +| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory | +| `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot | +| `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election | +| `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election | +| `client/store` | React-free store contract 与实现 | 领域对象、React hook、Slot 生命周期 | +| `client/ui-renderer` | SlotRegistry、scope binding、selector hook、outlet 和 React root | Session、Workspace 与 Conversation 业务逻辑 | + +## 总体数据流 + +Session 数据按以下路径进入 UI: + +```text +[ctx.remote.session] + | + v +[api/session-controller/client] + |-- SessionListState --------------------------> [ui-session] -> useSessions + |-- SessionSnapshot ----------------------------> [ui-session] -> useSession + |-- ProjectionValueSource ----------------------> [ui-session] -> useProjection + `-- per-Session SessionEventSource + | + v + [client/ui-conversation] + | + | assemble + v + ConversationSnapshot ----------------> useConversation + | + |---------+----------| + v v + [ui-chat] [ui-trajectory] + | | + useChat useTrajectory +``` + +Workspace 数据从 `ctx.remote.workspace` 进入 Workspace Controller,再由 `ui-workspace` 暴露为 `useWorkspaces`;需要跨域导航时,`ui-workspace` 临时读取 Session Controller 并发出选择或命令。 + +Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI owner。Owner 发布 Pending 对象,`ui-session.pendingInteractions` 再把同一对象送往 Session 导航状态和 Conversation composer selection。 + +## Session Controller Client + +### SessionSnapshot 的范围 + +`SessionSnapshot` 表示 Session 自身的控制与生命周期事实。它可以包含 identity、running、removed、blank、subagent address、open phase、history phase、prompt error、agent error 和 queue 状态。 + +它不包含以下数据: + +- raw event array; +- Conversation View; +- Chat Node; +- Trajectory row; +- Approval 或 Question 的待处理对象; +- 要求调用者遍历 event 才能解释的呈现状态。 + +字段由 event、control frame 或本地命令推导,并不自动决定其 owner;消费语义决定 owner。`composerPhase` 同时依赖 Session lifecycle 与 Conversation target activity,因此由 `ui-conversation` 合成,不进入 `SessionSnapshot`。 + +### 三个读取面 + +Session Controller 对外提供三个互不替代的读取面: + +1. 全局 Session list 与 current selection source,供导航和 `useSessions` 使用。 +2. 每个 Session 的逻辑 binding,包含 `sessionId`、`SessionSnapshot` source、commands 与 projection sources。 +3. Conversation-facing `SessionEventSource`,只供 Conversation assemble core 使用。 + +普通 UI component 不直接读取 `SessionEventSource`。`ui-session` 不读取私有 event window,`ui-conversation` core 也不接收 React binding 或 Slot API。 + +### SessionEventSource + +`SessionEventSource` 暴露已经物化的事件窗口,而不是 transport。 + +窗口携带有序 `entries`、`hasMore`、单调 `revision`,以及 `replace | prepend | append` 变更描述。Append 以常数时间连接不可变片段;需要完整 `entries` 数组的消费者才为该 snapshot 物化并缓存数组。 + +首次打开、重连、gap repair 和无法证明连续性的更新发布 `replace`;历史分页发布 `prepend`;连续 live event 发布 `append`。Conversation core 依据 revision 与 change 选择增量更新或完整 rebuild。 + +`MutableSessionEventSource` 是 Session Controller 内部写端,消费者只依赖只读的 `SessionEventSource`。 + +### Session binding 生命周期 + +每个 Session binding 持有自己的 Cordis Context 与 Fiber。Session Controller 创建 binding,也负责释放它。 + +依赖 Session 的对象把清理注册到 `binding.ctx.effect()`。Binding 释放会触发 Conversation binding、UI materialization 和 scoped Slot store 的清理,不存在额外的 `onBindingRelease` 或 `onRelease` 回调协议。 + +这种清理方式不要求 Session Controller 了解上层消费者名册。 + +## UI Session + +### 服务职责 + +`client/ui-session` 是 Session Controller 与 React/Slot 系统之间唯一的 Session adapter。它提供 `ctx.uiSession`,并负责: + +- 观察 Session list、current selection 和 per-Session binding; +- 安装 session 与 session-maybe scope adapter; +- 提供 `SessionProvider` 的呈现语义; +- 内建 session snapshot、projection 和 sessionId source; +- 接收其他领域 package 的 Session-scoped source contribution; +- 聚合业务 package 注册的 pending interaction。 + +它不拥有 Session transport、event folding、Conversation target 或具体业务结果。 + +### 标准 source 注册 + +领域 package 调用 `ctx.uiSession.provide()` 注册 bare source。Descriptor 静态声明 hooks、keyedHooks 和 props 名册,`resolve(binding)` 为一个 Session binding 返回完全对应的值;例如 `ui-conversation` 把每个 binding 的 snapshot 注册为 `conversation` hook source。 + +普通 source 被 renderer 转换成 `use`,Projection 等开放 key 空间通过 keyed hook resolver 暴露,稳定值通过 props 暴露。 + +运行时拒绝未声明、缺失或重复的标准 prop。`ui-session` 自身也走相同 materialization,renderer 不为 Session 名字写特殊分支。 + +### Scope binding + +session 与 session-maybe 使用同一个 adapter,但绑定语义不同: + +- strict session scope 在没有 current binding 时拒绝渲染; +- session-maybe 使用稳定 absent binding,保持 hook 调用顺序; +- current Session 切换以 `sessionId` 为 key 重建严格 Session subtree; +- root 与 session-maybe entry 可以跨 Session 切换常驻。 + +每个真实 materialized binding 保留 Controller binding 的 Context。`ui-session` 通过 `binding.ctx.effect()` 删除缓存项并撤销 current binding。 + +Contribution roster 变化会重建已 materialize 的 binding 并发布新的 source 集合。同一 binding 生命周期内,source identity 保持稳定,以满足 `useSyncExternalStore` 的缓存要求。 + +### SessionProvider + +`SessionProvider` 是 `PropsRenderSlots` 根据 session-scoped child 声明派生的标准席,不是业务 component 直接 import 的 React Context。 + +它接收普通 `ReactNode` children,不接收 `(sessionId) => ReactNode` render function;调用方直接用它包裹 `renderSlot('details', {})`。 + +Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provider 只负责 absent branch 与按 Session identity 隔离 subtree,组件不得借助 Provider 回调取得 Session 数据。 + +### Pending interaction + +`SessionPendingInteractionMap` 由业务 package declaration merge 扩展。每个 pending object 至少携带稳定 `key`、领域 `kind` 和 `sessionId`;`ui-session` 不 import Approval 或 Question 的具体类型。 + +业务 plugin 在 `apply()` 中调用 `registerPendingInteraction(precedence)`,为自己的 pending domain 建立稳定注册。该调用返回逐请求 publication function;publication function 同时发布精确对象及其 waterfall 委托回调,并返回移除该对象的幂等 disposer。Plugin teardown 会先移除所有已发布对象,再调用并等待其委托回调,避免 Client 回答者卸载后 Host 请求继续悬挂。 + +相同 key 的并发对象被拒绝,替换请求必须使用新 key。同一 Session 可以同时存在多个领域或多个请求。 + +`ui-session` 使用各 domain 的 precedence 选出每个 Session 当前生效的对象。较高 precedence 胜出,相同 precedence 下后遍历到的有效对象胜出。 + +聚合结果发布为 `pendingInteractions: ObservableSnapshot>`,`useSessionPendingInteraction` 是其 React 读取面。 + +Session 导航状态和 composer takeover 必须读取同一个 effective object,不得分别维护 status map 或 takeover roster。 + +## Workspace Controller 与 UI Workspace + +### WorkspaceSnapshot 的范围 + +`WorkspaceSnapshot` 只包含 Workspace Controller 拥有的 Host-authoritative 数据,包括 Workspace rows、顺序、archive set、follow phase 和错误。Workspace row 的 `sessionIds` 是关联字段,不等于把 Session 对象复制进 Workspace snapshot。 + +以下组合事实不进入 `WorkspaceSnapshot`: + +- Workspace 与 Session 两条 baseline 是否同时 ready; +- 根据 Session 更新时间推导的最近 Workspace; +- 当前 Session 是否因归档而清除; +- New Session 应复用哪个 blank Session; +- 首次启动应选择哪个 Session。 + +### UI Workspace 的组合职责 + +`client/ui-workspace` 把 Workspace list source 注册为 root 标准 source `workspaces`,renderer 由此提供 `useWorkspaces`。 + +初始选择、blank Session 复用、新建导航、并发创建合并和归档后导航属于 UI navigation policy。该 policy 可以在决定时同时读取 `ctx.workspaces` 与 `ctx.sessions`,但只调用 Controller command 和 selection action,不发布联合 snapshot。 + +目录 picker、目录浏览和 `openPath` 属于独立目录能力,不进入 Workspace Controller。 + +## UI Conversation + +### Assemble core + +`client/ui-conversation` 同时包含不依赖 React 的 Conversation assemble core 和同领域的 React adapter。 + +Core 拥有 `ConversationSnapshot`、Definition registry、View registry、event assembler、location index、每 Session binding、target source 和 target activity。 + +Core 从 Session binding 取得 `SessionEventSource`。连续 revision 的 append 与 prepend 使用增量组装;replace 或 revision 断档从完整窗口 rebuild。 + +Definition 或 View roster 变化只重建 Conversation binding,不重建 Session 或重开 Remote stream。Core 不 import React,可独立测试事件折叠、增量更新和 registry lifecycle。 + +`ConversationSnapshot` 不复制 `SessionSnapshot`,也不暴露 raw events;它只发布 target-neutral 的 View 名册、target activity 和 target source lookup。 + +`useSession` 与 `useConversation` 来自两个 source,不承诺在同一个 React commit 原子发布。同时读取两者的组件按当前 snapshot 纯计算,不把通知顺序解释为业务因果。 + +### Definition 与 View registry + +`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。 + +Registry 拒绝重复 key,保持注册顺序并返回幂等 disposer。Roster 变化时,现有 Conversation binding 使用当前 event window 重建;同一同步注册轮次中的变化会合并为一次 microtask 重建。 + +Target package 通过 declaration merge 扩展 snapshot 与 location data map,再向 registry 注册自己的 Definition、builder 和 View。注册随 Cordis effect 释放。 + +`ui-conversation` 不 import 具体 target package。 + +### Conversation React adapter + +React adapter 把每个 Conversation binding 的 snapshot 注册为 Session 标准 source `conversation`,renderer 由此提供 `useConversation`。 + +同包还拥有 shell、input、composer chain、queue UI、draft、View navigation 和 phase 合成;Core 不读取 React Context、Slot props 或 component state。 + +View 选择顺序固定为:有效的持久化 selection、已注册的 `chat`、无 View。无效 selection 不覆盖持久化值,系统不 fallback 到第一个已注册 View。 + +没有 `ui-chat` 时 shell 仍能激活和 mount,但不会隐式选择 Trajectory 或其他 target。 + +Shell phase 由 Session lifecycle 与 Conversation target activity 纯合成。Session 已 active 或任一 target 报告可见内容时显示 active;首条 prompt 失败仍保持 engaging。 + +### Input 与 composer + +Composer chain 属于 `ui-conversation`,具体 takeover 属于业务 package。`ConversationRoot` 从 `useSessionPendingInteraction` 读取当前 Session 的 effective object,并作为 `ComposerChainProps.pendingInteraction` 交给 chain selector。 + +Selector 是 owner currency 的纯函数,非 null 结果作为 `matched` 传给获选 component。Stable composer entry 与默认 composer 可以同时常驻,chain 只选择一个有效呈现。 + +Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue command 通过 Session-scoped service 寻址,不把 queue UI 写入 Conversation core。 + +## Chat 与 Trajectory target + +### Chat owner + +`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache。 + +它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。 + +Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。 + +历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。 + +### Trajectory owner + +`client/ui-trajectory` 通过相同 target 协议注册 `trajectory`。它拥有事件记录、时间线、虚拟行、selection 和 inspection view,并通过标准 source 提供 `useTrajectory`。 + +Session 生命周期读取 `useSession`,Trajectory 数据读取 `useTrajectory`。Trajectory 不通过 Session snapshot 或 Chat snapshot 取得自己的数据。 + +其他 target 使用同一注册流程,不修改 renderer、Session Controller 或 ui-session。 + +## Approval 与 Question + +### 稳定注册 + +Approval 和 Question 的 plugin 安装分为稳定注册与单次请求处理。`apply()` 注册 locale、调用 `registerPendingInteraction()` 注册本领域 pending domain,并向 `conversation.composer` 注册唯一稳定 entry。 + +Approval 的 detail child Slot 也由稳定 entry 声明。并发请求和 Session 数量不会增加 composer entry 或重复声明 Slot,所有注册随 plugin fiber 释放。 + +### 单次 waterfall 请求 + +Remote Event listener 从自身 Agent Context 解析 Session。没有 Session scope 时调用 `next()` 继续 waterfall;存在 Session scope 时创建 `PendingApproval` 或 `PendingQuestion`。 + +Listener 通过已注册 domain 的 publication function 发布对象,等待用户完成、取消或请求 signal 中止,并在 `finally` 中精确移除对象。 + +单次请求不注册 Slot,不创建第二套 lifecycle effect,也不修改 Session snapshot。 + +Approval 暴露 allow 与 reject,Question 暴露 answer 与 cancel。用户主动取消 Question 返回 `ASK_CANCELLED`;等待中的请求被 `AbortSignal` 中止时返回 `UserQuestionError(ASK_ABORTED)`,不泄漏载体的 `AbortError` 或普通 `Error`。 + +Gateway 只要求 Remote Event 参数和结果是合法 JSON 传输值,不复制 Question 选项的领域校验。 + +### 单一 pending 投影 + +Sidebar 与 composer 使用相同 `pendingInteractions` snapshot。导航根据 effective object 的 `kind` 显示审批、计划审阅或问题状态,composer entry 根据对象实例选择自己的面板。 + +同一请求 identity 同时驱动两处 UI。新请求替换同类型旧请求时使用新 key,因此 selector 与订阅者都观察到身份变化。 + +`ui-session` 只实现跨领域 precedence,不解释 Approval 或 Question 的字段。 + +## UI Renderer 与 Store + +### UI Renderer + +`client/ui-renderer` 拥有 `SlotRegistry` service 和 React renderer。它负责: + +- `ctx.slots.register()`、`inject()`、`renderSlot()` 与声明生命周期; +- root、session 和 session-maybe scope adapter; +- 标准 observable source 到 selector hook 的绑定; +- Slot outlet、错误隔离、root mount 与 hydration; +- 按 scope key 管理 Slot store instance 生命周期。 + +Renderer 可以认识通用 scope 名称和 binding 协议,但不读取领域 service。渲染 Session scope 而没有安装 adapter 是装配错误,并立即失败。 + +### Store + +`client/store` 是 React-free 普通库,拥有 `ObservableSnapshot`、`SnapshotStore`、`defineStore`、`createSnapshotStore` 和 `shallowEqual`。 + +`ui-slots` 引用 store contract,`ui-renderer` 管理 store instance 并提供 `useStore`。 + +Store 只承载 draft、View selection、Chat selection、inspection request 和面板尺寸等观看或交互状态。Session、Workspace、Conversation、Remote stream 和 connection generation 不进入 Store。 + +### 注册与释放顺序 + +一个 plugin 同时提供 source 与 Slot entry 时,先注册 source,再注册 entry。Cordis 反向 disposal 先移除 entry,再移除 source,仍挂载的 entry 因而不会短暂失去必需 hook。 + +Session binding 释放通过 `binding.ctx.effect()` 清理 UI materialization 与 scoped store。Plugin fiber 释放通过 registration disposer 清理 source、listener 和 Slot entry。 + +所有 disposer 都可重复调用,不依赖 Cordis 生命周期以外的隐式回调。 + +## 组合与依赖方向 + +应用 bundle 显式安装所需 Controller、adapter、target 和 renderer plugin。每个 owner 的 `apply()` 只安装自己的 service、listener 和 contribution。 + +运行时消费方向是 `session-controller → ui-session → ui-conversation → target UI`、`workspace-controller → ui-workspace` 和 `store → ui-slots → ui-renderer`;Approval 与 Question 只依赖 `ui-session` 提供的 pending 注册点。 + +图中的箭头表示运行时消费关系,不覆盖 type-only declaration merge 边。Controller 不反向依赖 UI adapter,renderer 不反向依赖领域 package,Conversation core 不依赖具体 target。 + +UI component 不接收 `ctx`。跨 package 协作使用 Cordis service、standard source 或 Slot registration,不新增聚合 facade。 + +## 开发者遵循方式 + +### 先确定数据 owner + +新增状态前先按消费语义确定唯一 owner:Host 通信、命令和实体生命周期归 API Controller;由 Session events 形成且与 target 无关的数据归 Conversation core;只服务一种 View 的投影归对应 target package;草稿、选择和面板状态归拥有该交互的 UI package。 + +同一事实不得同时保存在 Controller snapshot、Conversation snapshot 和 Store。需要跨域决策时读取多个 source 并立即发出 command,不创建联合 snapshot,也不缓存另一领域的对象副本。 + +以下信号表示 owner 选择错误:Controller 开始 import React;renderer 出现业务类型分支;组件遍历 Session events;Store 保存 Session 或 Workspace 实体;一个 target 的变化要求修改 Session Controller。 + +### 新增 Session-scoped 数据 + +1. 在领域 owner 中提供 React-free observable source。 +2. 在所属 UI adapter 中 declaration-merge 标准 prop 类型。 +3. 通过 `ctx.uiSession.provide()` 声明固定 roster,并从 Session binding 解析 source。 +4. 让 Slot component 从 `PropsRuntime` 获得生成的 hook,不向组件传 `ctx`。 +5. 把每个 binding 的资源清理挂到 `binding.ctx.effect()`,把 registration 清理留给 plugin fiber。 +6. 测试缺失值、重复名字、roster 替换、Session 切换和 binding disposal。 + +只有开放 key 空间使用 keyed hook;有限且稳定的 source 使用普通 hook;不会变化的标识使用 prop。不得为了减少一次注册而把业务名称硬编码进 renderer。 + +### 新增 Conversation target + +1. 在 target package 中扩展 Conversation snapshot 或 location data map。 +2. 向 `UiConversation.events` 注册所需 event Definition。 +3. 向 `UiConversation.views` 注册 snapshot builder、target id、View 与 activity 规则。 +4. 通过 `ctx.uiSession.provide()` 暴露该 target 的标准 selector hook。 +5. 在同一 package 中注册 renderer、locale 和 target-specific Slot entry。 +6. 验证 target 卸载只重建 Conversation binding,不改变 Session、其他 target 或 Remote stream。 + +Target 不得读取另一个 target 的 snapshot 作为自己的数据源。可选协作通过窄 port 或 Slot 完成;缺失 target 时,shell 必须保持可启动且不得猜测 fallback。 + +### 新增 pending-interaction 业务 + +1. 业务 package 定义 Pending 对象及其完成、取消和中止语义。 +2. 通过 declaration merge 把对象加入 `SessionPendingInteractionMap`。 +3. 在 `apply()` 中调用 `registerPendingInteraction()` 一次,并注册唯一稳定的 composer entry。 +4. Remote waterfall listener 从 Agent Context 解析 Session;无法处理时调用 `next()`。 +5. 可处理时创建 Pending 对象,使用 publication function 发布,等待结果,并在 `finally` 中移除。 +6. 测试并发 key、precedence、用户取消、transport abort、plugin disposal 和无 Session delegation。 + +单次请求不得注册 Slot、声明 child Slot、修改 Session snapshot 或另建状态索引。Sidebar 与 composer 都从 `useSessionPendingInteraction` 读取同一个 effective object。 + +### Review 检查点 + +- 每个新 source、registry contribution、listener 和 cache 都有明确 Cordis fiber 或 Session binding owner。 +- 每个公共 hook 能追溯到唯一 React-free source;不存在只为传参而层层转发的 selector。 +- 每个 component 的数据与 action 都来自标准 props 或所属 Slot inject face。 +- 每个 target 在缺席、动态注册和卸载时都有定义明确的结果。 +- 每个跨层 import 都沿 Controller、adapter、renderer、component 的单向关系前进。 +- 每个错误由最早能解释其语义的 owner 归类;载体错误不直接泄漏成业务错误。 + +## 验证 + +各 owner 的测试分别固定 Controller binding 与 event source、UI scope 与 pending precedence、Conversation 增量组装与 View fallback、target projection、waterfall 结果以及 renderer 的 scope/store 生命周期。应用组装测试同时覆盖完整 roster 和缺少具体 target 的启动;组件测试不替代对象层、重放和生命周期测试。 + +## 备选方案 + +- **保留 Runtime facade。** 它维持单一入口,却继续形成依赖汇点并允许新代码绕过领域 owner;系统因此不保留 facade 或兼容出口。 +- **把所有 Client 状态放进 API Controller。** 这会让协议对象承担 React、View 和 presentation policy;Controller 因而只保留无 React 的领域状态。 +- **让 Controller 直接提供 React hooks。** 这会阻止非 React 消费者复用同一对象,也使 transport 与 renderer 生命周期相互依赖。 +- **把 Conversation 放进 SessionSnapshot。** 这会扩大 Session API,并迫使普通 Session 消费者理解 event folding 与 target roster。 +- **让 Chat 和 Trajectory 各自重放 Session events。** 这会重复维护顺序、location 和 registry rebuild;共享 assemble core 因而留在 `ui-conversation`。 +- **把 Conversation core 拆成额外的非 UI package。** Core 与 adapter 当前共同演化且没有其他非 UI package 消费者;同包目录隔离足以保持 React-free core。 +- **把 Workspace 与 Session 合成联合 snapshot。** 这会制造新的跨域 owner;跨域逻辑保留为 `ui-workspace` 的即时决策。 +- **让 renderer 内建所有标准 hook。** 这会要求通用基础设施认识每个领域;standard source registration 保持 renderer 与业务类型解耦。 +- **让每个 pending 请求动态注册 composer entry。** 这会重复声明 child Slot,并让并发请求竞争注册顺序;稳定 entry 与请求期对象发布保持分离。 +- **把 pending interaction 写回 Session projection。** 待回答 waterfall 不是已提交的持久 Session 事实,刷新恢复由 Remote Event replay 负责,因此它留在业务 UI source。 +- **为 binding 增加专用 release callback。** 这会重复 Cordis 生命周期;`binding.ctx.effect()` 已能把消费者清理挂到同一 owner。 +- **让 SessionProvider 通过 render function 传 Session id。** 这会产生另一条数据注入路径;普通 children 与标准 `sessionId` prop 保持 scope 数据只有一个入口。 +- **把 Store 留在 renderer。** Store contract 不依赖 React,并被对象与测试基础设施复用;独立 `client/store` 保持 engine 与渲染生命周期分离。 + +## 后果 + +Session、Workspace、Conversation 与具体 target 各自拥有一份权威状态,非 React consumer 可以直接复用 Controller 和 assemble core。新增 Conversation target 只需注册 Definition、builder、View、标准 source 和 Slot entry;新增 pending-interaction 业务只需声明类型、注册 domain 并提供稳定 composer entry。 + +Renderer 和 Session Controller 不因新增业务领域而增加分支,Session binding 与 plugin fiber 则提供两条明确且可组合的释放路径。UI 可以观察到 Session 与 Conversation source 的独立发布,消费者不得依赖二者的通知顺序。 + +组合包必须显式装载所需 adapter 与 target plugin。缺失具体 target 时 shell 仍可运行,但不会生成或猜测该 target 的 View。更多 package 和显式注册增加了装配工作,但依赖方向、测试范围与故障 owner 均可局部确定。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 6db4668e86..deb8efc160 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: e4c9fdd6fcdec0b14ed2724dd58365e1d98af8b4 -2026-07-30-web-transcript-log-ordered-projection.zh.md: 625bd993a0b20bbbed318edf71d79866fa80bff0 +2026-07-30-web-transcript-log-ordered-projection.md: ed6fa4df3ac25bf6fe2947e6fb7bb7cbde6ac00a +2026-07-30-web-transcript-log-ordered-projection.zh.md: cf8dc35099f416356310081f758d0b89fe8282b9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index e4c9fdd6fc..ed6fa4df3a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -37,9 +37,9 @@ import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/che const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact' ``` -Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compaction/compaction"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. +Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compaction/compaction"}` in `packages/client/ui-chat/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. -`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. +`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 625bd993a0..cf8dc35099 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -37,9 +37,9 @@ import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/che const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact' ``` -重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compaction/compaction"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 +重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/ui-chat/tsconfig.json` `references` 中的 `{"path": "../../compaction/compaction"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 -`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 +`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml index 9f352def8f..a148348ba4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md -2026-08-05-workspace-blank-session-reuse-membership.md: 0d46a0ccf6924c508db2e6c0f3591468e2956722 -2026-08-05-workspace-blank-session-reuse-membership.zh.md: 6350c6773265edebb381d5ca10fd5126a5722a5a +2026-08-05-workspace-blank-session-reuse-membership.md: df8cc898f5a80937b8aac69ebd51a6a93882971b +2026-08-05-workspace-blank-session-reuse-membership.zh.md: 73ccdbbb002920eea8f7b01bc0fa8a18859bd3ee diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md index 0d46a0ccf6..df8cc898f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -26,4 +26,4 @@ Stray blank sessions remain visible in Ungrouped (the user can still open them) ## Testing -`packages/client/runtime/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. +`packages/client/ui-workspace/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md index 6350c67732..73ccdbbb00 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -26,4 +26,4 @@ Status: implemented ## 测试 -`packages/client/runtime/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 +`packages/client/ui-workspace/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml index a28a57cfd5..841f1c8442 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md -2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 66a1ca361cf28bf0beab95fa81da9cac3527474c -2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: 6aa1866e802b00d0a3431dfd5bef9efd57121294 +2026-08-06-reader-scroll-attribution-observed-top-ledger.md: b55bbc39f6e1f24bb7751b7743da23736abbd06b +2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: e38ead6c9b80e41b37556172bd6c1f411858b6ed diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md index 66a1ca361c..b55bbc39f6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md @@ -18,7 +18,7 @@ A shrink clamp whose layout regrows within the same rendering update before the ## Testing -Unit specs in `packages/client/ui-conversation/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. +Unit specs in `packages/client/ui-chat/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md index 6aa1866e80..e38ead6c9b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md @@ -18,7 +18,7 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输 ## 测试 -`packages/client/ui-conversation/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 +`packages/client/ui-chat/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml index 31468d0ea3..f5724092a0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card.md -2026-07-30-web-result-card.md: 591471d219295019d28b9eeaf2e57f6d2115d380 -2026-07-30-web-result-card.zh.md: c850c148ba32ca782fd1d6d7e5c3bbfa758391c5 +2026-07-30-web-result-card.md: 35ad06998136cbffcf4a049cb0c68adb97498b68 +2026-07-30-web-result-card.zh.md: 81656b5f659996d8a30ee293ccbbf562c7e6dd85 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md index 591471d219..35ad069981 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -22,7 +22,7 @@ Neither result view carries a `content` copy. A UI that does not render the stru ## Consequences -The frontend consumer is owned by the [web result card frontend note](2026-07-30-web-result-card-frontend.md): this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change. +The frontend consumer is owned by the [web result card frontend note](2026-07-30-web-result-card-frontend.md): this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. Session Controller carries the event's typed `surfaceOp` without redeclaring card tags ([wire type](../../../../packages/api/session-controller/src/types.ts)), so the new view crosses the wire without a schema change. A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md index c850c148ba..81656b5f65 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -22,7 +22,7 @@ Status: implemented ## Consequences -前端消费方属于 [Web result card 前端 note](2026-07-30-web-result-card-frontend.zh.md) 的工作范围:本次生产者变更新增约定分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript(文本记录)快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。 +前端消费方属于 [Web result card 前端 note](2026-07-30-web-result-card-frontend.zh.md) 的工作范围:本次生产者变更新增约定分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript(文本记录)快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。Session Controller 直接携带事件中已类型化的 `surfaceOp`,不重新声明 card 标签([线路类型](../../../../packages/api/session-controller/src/types.ts)),因此新视图无需 schema 变更即可跨 wire。 未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml index a20606b5ed..6cc0c25b8e 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md -2026-08-02-web-thinking-tail-scroll.md: b9aa47a01b4d8e22baddac1b03f52b3524250941 -2026-08-02-web-thinking-tail-scroll.zh.md: 41fe29f06202aef3307d756201eb4745fecca537 +2026-08-02-web-thinking-tail-scroll.md: 38c27274b2c85974044c2bb467c1519e19bfd148 +2026-08-02-web-thinking-tail-scroll.zh.md: bf637228b3a793f318fa5a5d7b0968ecd8e081d5 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md index b9aa47a01b..38c27274b2 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -28,4 +28,4 @@ The collapsed row now communicates provider cadence through content motion as we ## Testing -`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. +`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md index 41fe29f062..bf637228b3 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -28,4 +28,4 @@ Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠 ## 测试 -`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的无密钥组装态 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 +`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的无密钥组装态 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 32c4650128..1ee7b9dfc3 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 1af949a537103637e8bac84b5bfa0f915fcf82e0 -2026-08-08-web-background-job-display.zh.md: 0aa8d65b7697312f603dd9ddd37f182da0b7f39c +2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27 +2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 1af949a537..8da6c2fd91 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -14,24 +14,24 @@ The session header was already the place where per-session background activity l ## Decision -Task state reaches the browser as **one whole-snapshot mux frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping. +Task state reaches the browser as **one whole-snapshot control frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping. This ships the list alone. Per-task streamed output and a human-initiated cancellation are separate phases, and the channel is shaped so neither has to undo it. ### Wire shape -One frame in the mux stream: +One frame in the Session Controller control stream: ```ts ignore-check -| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] } +| { type: 'jobs'; sessionId: SessionId; jobs: SessionJob[] } ``` -`JobView` is browser-safe and owned by the carrier at [`packages/host/apiproxy/src/api/jobs.ts`](../../../../packages/host/apiproxy/src/api/jobs.ts), alongside the other domain contracts, with its wire schema beside it in `jobs.schema.ts`: +`SessionJob` is browser-safe and owned beside the other Session Remote contracts in [`packages/api/session-controller/src/types.ts`](../../../../packages/api/session-controller/src/types.ts): ```ts import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -export interface JobView { +export interface SessionJob { id: JobId kind: string label: string @@ -48,7 +48,7 @@ export interface JobView { Three `JobSnapshot` fields are deliberately absent: `ownerSession` (the frame's `sessionId` already carries it), `reported` (an internal notice-delivery bit with no user meaning), and `outputLimitBytes` (producer-owned model-presentation policy). -The frame carries a whole snapshot rather than a delta for the reason [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) states for itself: start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small. +The frame carries a whole snapshot rather than a delta so start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small. ### The task-registry change feed @@ -66,16 +66,16 @@ The listener is owner-granular rather than task-granular. The only consumer push Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. -### The api-proxy carrier +### The Session Controller carrier -`mux()` subscribes `ctx.jobs.onJobsChanged` and pushes `session/jobs`; the subscription baseline rides next to the existing `session/subscribed` control frames, so a reconnecting client is current before it renders. +[`SessionControlController.control()`](../../../../packages/api/session-controller/src/control.ts) emits one complete Host-wide baseline before later `jobs` replacement frames. Every physical reconnect opens a new generation, so the client replaces its process-local mirror before applying further changes. Four rules the carrier keeps: -- **Never resume.** A change push reads `jobs.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.jobs.list(ctx.agents.get(session.id))` — the non-resuming registry read, where a session with no live Agent correctly yields only the unowned tasks. Neither path touches the [`api-remotes` Agent resolver](../../../../packages/api/remotes/src/agent-lookup.ts), which resumes a cold session as a side effect of lookup; listing must never revive a session the user merely scrolled past. -- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every subscribed session, because unowned tasks are visible to every caller. -- **Stay optional.** The carrier reads `ctx.get('jobs')`. A composition without the registry emits no frames, and the client renders no entry point — the posture `sessionProjections` already has in this file. -- **Say nothing about nothing.** The baseline is pushed only for sessions whose list is non-empty, and an absent key on the client means an empty list. A change that empties a list still pushes `[]`, because that one transition is the only thing the client cannot infer from absence. +- **Never resume.** A change push reads `jobs.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.jobs.list(ctx.agents.get(session.id))`, where a Session with no live Agent correctly yields only unowned tasks. Neither path calls the [Session Controller Agent resolver](../../../../packages/api/session-controller/src/agent.ts), because listing must never revive a Session the user merely scrolled past. +- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every attached Session, because unowned tasks are visible to every caller. +- **Stay optional.** The carrier reads `ctx.get('jobs')`. A composition without the registry reports empty job sets, and the client renders no entry point. +- **Represent emptiness explicitly.** The opening baseline contains an entry for every attached Session, including `[]`; a later change that empties one list also pushes `[]`. The client may then normalize an empty set to an absent key without retaining stale rows. ### The client mirror @@ -83,7 +83,7 @@ Four rules the carrier keeps: It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel. -Two clears keep it honest. On re-subscribe the manager drops the session's mirror — the rule `session/queue` already follows, because a fresh baseline is arriving and this generation sends none for an empty set, so a retained list would survive as a phantom. On `host/session-removed` it drops the mirror again: owner disposal already removed the records registry-side, but that lands on the mux stream while the removal frame rides the host stream, so the two have no relative order. +Two replacement points keep it honest. Each control-stream generation clears the complete jobs mirror before installing the new baseline's non-empty sets. An `api-session/removed` event also drops that Session's entry, independently of the job-registry disposal notification's ordering. ### The header action @@ -101,7 +101,7 @@ A running one-shot background subagent therefore appears both there and in the s ## Alternatives considered -**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `jobs-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window. +**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `jobs-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/api/session-controller/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window. **Popover-scoped polling with no seam change.** Cheapest to build and the only option that avoids touching `JobRegistry`. It cannot support a resident count on the trigger without a resident poll, and both later phases need a real change feed anyway, so it buys a week and spends it back. @@ -117,7 +117,7 @@ A running one-shot background subagent therefore appears both there and in the s The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. -Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`api-proxy-jobs`](../../../../packages/host/apiproxy/tests/api-proxy-jobs.spec.ts) pins the baseline-only-when-non-empty rule, the three change pushes, the dropped internal fields, the unowned fan-out, the no-resume guarantee, and the registry-absent composition; and the client suites pin the last-wins fold, the absent-key representation, both clears, and the component's ordering, duration, and dismissal behavior. +Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 0aa8d65b76..0e05ef9d2f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -14,24 +14,24 @@ Status: implemented ## 决策 -任务状态以**每会话一帧的整份快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。 +任务状态以**每会话一帧的整份 control 快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。 本次只交付列表。每个任务的流式输出与人类发起的中断是各自独立的阶段,而通道的形状让两者都不必推翻它。 ### 线路形状 -mux 流中的一帧: +Session Controller control 流中的一帧: ```ts ignore-check -| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] } +| { type: 'jobs'; sessionId: SessionId; jobs: SessionJob[] } ``` -`JobView` 是浏览器安全类型,由载体在 [`packages/host/apiproxy/src/api/jobs.ts`](../../../../packages/host/apiproxy/src/api/jobs.ts) 里拥有,与其他领域契约并列,线路 schema 就在旁边的 `jobs.schema.ts`: +`SessionJob` 是浏览器安全类型,与其他 Session Remote 约定一起由 [`packages/api/session-controller/src/types.ts`](../../../../packages/api/session-controller/src/types.ts) 拥有: ```ts import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -export interface JobView { +export interface SessionJob { id: JobId kind: string label: string @@ -48,7 +48,7 @@ export interface JobView { `JobSnapshot` 的三个字段被刻意省去:`ownerSession`(帧的 `sessionId` 已经带了)、`reported`(内部的通知投递位,对用户无意义),以及 `outputLimitBytes`(生产者拥有的模型呈现策略)。 -这一帧带整份快照而非增量,理由就是 [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) 为自己写下的那条:启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。 +这一帧带整份快照而非增量,因此启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。 ### 任务注册表变更订阅 @@ -66,16 +66,16 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void 服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 -### api-proxy 载体 +### Session Controller 载体 -`mux()` 订阅 `ctx.jobs.onJobsChanged` 并推送 `session/jobs`;订阅 baseline 紧挨着既有的 `session/subscribed` 控制帧发出,让重连的客户端在渲染前就是最新的。 +[`SessionControlController.control()`](../../../../packages/api/session-controller/src/control.ts) 先发出一份完整的 Host 范围 baseline,再发送后续 `jobs` 替换帧。每次物理重连都会打开新一代流,因此客户端会先替换进程本地镜像,再应用后续变更。 载体守着四条规则: -- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `jobs.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.jobs.list(ctx.agents.get(session.id))`——不触发 resume 的注册表读法,没有活体 Agent 的会话正确地只得到无主任务。两条路径都不碰 [`api-remotes` 的 Agent 解析器](../../../../packages/api/remotes/src/agent-lookup.ts),那个解析器会把查询变成复活冷会话的副作用;列个任务不该让用户随手划过的会话活过来。 -- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已订阅会话推一份新快照,因为无主任务对所有调用方可见。 -- **保持可选。** 载体读 `ctx.get('jobs')`。没有挂注册表的组合不发任何帧,客户端也就不渲染入口——`sessionProjections` 在这个文件里已经是这个姿态。 -- **没有就不说。** baseline 只为列表非空的会话推送,客户端上键缺失即表示空列表。把列表清空的那次变更仍然推 `[]`,因为这一个转换是客户端唯一无法从「缺失」推断出来的东西。 +- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `jobs.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.jobs.list(ctx.agents.get(session.id))`,没有 live Agent 的 Session 正确地只得到无主任务。两条路径都不调用 [Session Controller Agent 解析器](../../../../packages/api/session-controller/src/agent.ts),因为列出任务绝不能复活用户随手划过的 Session。 +- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已挂接 Session 推一份新快照,因为无主任务对所有调用方可见。 +- **保持可选。** 载体读 `ctx.get('jobs')`。没有挂注册表的组合报告空任务集,客户端也就不渲染入口。 +- **显式表示空集。** opening baseline 为每个已挂接 Session 提供一项,包括 `[]`;后续变更清空一个列表时也会推送 `[]`。客户端因此可以把空集归一化为缺失键,而不会保留陈旧行。 ### 客户端镜像 @@ -83,7 +83,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void 它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。 -两处清理让它保持诚实。重新订阅时 manager 丢弃该会话的镜像——`session/queue` 已经遵循的规则,因为新的 baseline 正在路上,而这一世代对空集不发 baseline,被留下的列表会变成幽灵。`host/session-removed` 时再丢一次:owner 销毁在注册表侧已经移除了记录,但那件事落在 mux 流上而这一帧走 host 流,两者没有相对顺序。 +两个替换点让它保持诚实。每一代 control 流都会先清空完整任务镜像,再安装新 baseline 中的非空集合。`api-session/removed` 事件也会删除该 Session 的条目,不依赖任务注册表 disposal 通知与它之间的顺序。 ### header 入口 @@ -101,7 +101,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void ## 备选方案 -**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `jobs-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。 +**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `jobs-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。 **只在弹层打开时轮询,不改 seam。** 最省事,也是唯一不碰 `JobRegistry` 的选项。它无法在不常驻轮询的前提下支持触发器上的常驻计数,而后面两期反正都需要一条真正的变更订阅,所以它省下一周又还回去。 @@ -117,7 +117,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void [web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 -在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`api-proxy-jobs`](../../../../packages/host/apiproxy/tests/api-proxy-jobs.spec.ts) 钉住「非空才发 baseline」、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证,以及没有注册表的组合;客户端各套件钉住 last-wins 折叠、缺失键表示、两处清理,以及组件的排序、时长与关闭行为。 +在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 ## 影响 diff --git a/apps/cli/tests/github-webhook-real.e2e.ts b/apps/cli/tests/github-webhook-real.e2e.ts index a49c529aa5..93421ea93a 100644 --- a/apps/cli/tests/github-webhook-real.e2e.ts +++ b/apps/cli/tests/github-webhook-real.e2e.ts @@ -2,7 +2,7 @@ import type { ChildProcess } from 'node:child_process' import { spawn } from 'node:child_process' -import { createHmac } from 'node:crypto' +import { createHmac, randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises' import { createServer } from 'node:net' @@ -33,7 +33,7 @@ interface SessionList { }> } -interface WorkspaceList { +interface WorkspaceBaseline { items: Array<{ path: string sessionIds: string[] @@ -109,28 +109,138 @@ async function freePort(): Promise { return port } -/** Invoke one public Web RPC method. */ -async function rpc(baseUrl: string, method: string, payload: unknown): Promise { - const response = await fetch(`${baseUrl}/api/${method}`, { +/** Invoke one public Remote method over its HTTP carrier. */ +async function remoteRpc(baseUrl: string, endpoint: string, args: object): Promise { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', - rpcId: `github-webhook-real-${method}`, - method, - payload, + rpcId: `github-webhook-real-${endpoint}-${randomUUID()}`, + method: endpoint, + payload: { args }, }), }) - if (!response.ok) throw new Error(`${method} returned HTTP ${String(response.status)}: ${await response.text()}`) + if (!response.ok) { + throw new Error(`${endpoint} returned HTTP ${String(response.status)}: ${await response.text()}`) + } const envelope = await response.json() as { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } if (!envelope.result.ok) { - throw new Error(`${method} failed: ${envelope.result.error.code}: ${envelope.result.error.message}`) + throw new Error(`${endpoint} failed: ${envelope.result.error.code}: ${envelope.result.error.message}`) } return envelope.result.value } +/** Read one opening item from a public Remote stream. */ +async function openingStreamItem( + baseUrl: string, + endpoint: string, + args: object, + accepts: (value: unknown) => boolean, +): Promise> { + const socket = new WebSocket(`${baseUrl.replace(/^http/u, 'ws')}/api/remote.mux`) + const streamId = `github-webhook-real-${endpoint}-${randomUUID()}` + try { + await new Promise((resolve, reject) => { + const cleanup = (): void => { + socket.removeEventListener('open', opened) + socket.removeEventListener('error', failed) + socket.removeEventListener('close', closed) + } + const opened = (): void => { + cleanup() + resolve() + } + const failed = (): void => { + cleanup() + reject(new Error(`${endpoint} carrier failed before opening`)) + } + const closed = (): void => { + cleanup() + reject(new Error(`${endpoint} carrier closed before opening`)) + } + socket.addEventListener('open', opened) + socket.addEventListener('error', failed) + socket.addEventListener('close', closed) + }) + return await new Promise>((resolve, reject) => { + const timer = setTimeout(() => { finish(new Error(`${endpoint} did not publish its opening item`)) }, 10_000) + const cleanup = (): void => { + clearTimeout(timer) + socket.removeEventListener('message', message) + socket.removeEventListener('error', failed) + socket.removeEventListener('close', closed) + } + const finish = (error: Error | undefined, value?: Record): void => { + cleanup() + if (error !== undefined) reject(error) + else if (value === undefined) reject(new Error(`${endpoint} opening item was absent`)) + else resolve(value) + } + const message = (event: MessageEvent): void => { + try { + if (typeof event.data !== 'string') throw new Error(`${endpoint} published a non-text frame`) + const frame: unknown = JSON.parse(event.data) + if (!isRecord(frame) || frame.streamId !== streamId) return + if (frame.type === 'error') { + finish(new Error(`${endpoint} failed: ${JSON.stringify(frame.error)}`)) + return + } + if (frame.type === 'end') { + finish(new Error(`${endpoint} ended before its opening item`)) + return + } + if (frame.type === 'item' && isRecord(frame.value) && accepts(frame.value)) { + finish(undefined, frame.value) + } + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + } + const failed = (): void => { finish(new Error(`${endpoint} carrier failed before its opening item`)) } + const closed = (): void => { finish(new Error(`${endpoint} carrier closed before its opening item`)) } + socket.addEventListener('message', message) + socket.addEventListener('error', failed) + socket.addEventListener('close', closed) + socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } })) + }) + } finally { + socket.close() + } +} + +/** Read the current Workspace baseline from a fresh follow generation. */ +async function workspaceBaseline(baseUrl: string): Promise { + const frame = await openingStreamItem( + baseUrl, + 'workspace/follow', + {}, + value => isRecord(value) && value.type === 'baseline' && isRecord(value.value), + ) + return frame.value as WorkspaceBaseline +} + +/** Read the explicit page cut from a fresh Session follow generation. */ +async function sessionCursor(baseUrl: string, sessionId: string): Promise { + const frame = await openingStreamItem( + baseUrl, + 'session/follow', + { request: { address: { kind: 'session', sessionId } } }, + value => isRecord(value) && value.type === 'opened' && Number.isSafeInteger(value.cursor), + ) + return frame.cursor as number +} + +/** Read Session history at the cursor explicitly opened for this page. */ +async function history(baseUrl: string, sessionId: string): Promise { + const throughSeq = await sessionCursor(baseUrl, sessionId) + return remoteRpc(baseUrl, 'session/page', { + request: { address: { kind: 'session', sessionId }, throughSeq, maxMessages: 100 }, + }) +} + /** Poll a public observation until it satisfies the test's behavior predicate. */ async function eventually( child: ChildProcess, @@ -258,16 +368,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real child, observation.text, 'one Workspace-attached Session', - async () => await rpc(baseUrl, 'workspace.list', {}), + async () => await workspaceBaseline(baseUrl), value => value.items.some(workspace => workspace.path === canonicalWorkspacePath && workspace.sessionIds.length === 1), 30_000, ) const workspace = workspaces.items.find(item => item.path === canonicalWorkspacePath) const sessionId = workspace?.sessionIds[0] - if (sessionId === undefined) throw new Error('workspace.list did not expose the webhook Session') + if (sessionId === undefined) throw new Error('workspace/follow did not expose the webhook Session') - const sessions = await rpc(baseUrl, 'session.list', {}) + const sessions = await remoteRpc(baseUrl, 'session/list', { _request: {} }) expect(sessions.items.find(session => session.sessionId === sessionId)).toMatchObject({ agentPreset: 'minimal', blank: false, @@ -278,7 +388,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real child, observation.text, 'webhook provenance, title, and permission events', - async () => await rpc(baseUrl, 'session.history', { sessionId, maxMessages: 100 }), + async () => await history(baseUrl, sessionId), (page) => { const events = page.events.map(item => item.event) const title = events.find(event => event.type === 'session/title') @@ -319,7 +429,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real child, observation.text, 'a real DeepSeek assistant response', - async () => await rpc(baseUrl, 'session.history', { sessionId, maxMessages: 100 }), + async () => await history(baseUrl, sessionId), page => assistantText(page).includes(MARKER), 150_000, ) diff --git a/apps/web/package.json b/apps/web/package.json index 5bdfe8ebd7..d1f70bedfc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web": "workspace:^", diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 5daf55b021..3c260eab76 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md -README.md: acb0c300bafe221f6a92f0168908bebf965377b9 -README.zh.md: 029f3190bb89bede5506d3d58f5e6df229218493 +README.md: 2104d9422cfbbcbc7ffc4b12e491c0a62daa3b9d +README.zh.md: 4dfa5b2f61c757e481d9b8e012b37a5e71d75093 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index acb0c300ba..2104d9422c 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -33,14 +33,11 @@ then surfaces as a missed selector or a stale mirrored value — a loud failure, never a silent pass. `scaffold.ts` follows this rule for the welcome-notice namespace, acknowledgement field, version, and asserted Chinese copy. -Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so +One kind of Client import stands. `assembled-boot.ts` drives the shell itself, so it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what -that harness is for, and both packages are already in the Host graph. Separately, -the chat scenarios import `conversationContextKey` from -`@deepseek-ai/dsh-client-runtime/client` because `client/runtime` is reachable -through the unsplit `directory-picker` packages and pulls nothing further in. -That reachability is incidental, not a guarantee — if it ever leaves the graph, -mirror the helper like the rest. +that harness is for, and both packages are already in the Host graph. The chat +scenarios mirror `conversationContextKey` in `support.ts` instead of importing +its Client owner. Nothing mechanically enforces this rule; keep it in review. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 029f3190bb..4dfa5b2f61 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -26,12 +26,10 @@ Client face,而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/re import 点明源模块。这样漂移会表现为选择器未命中或镜像值过期——是响亮的失败,绝不会是静默 通过。`scaffold.ts` 按此规则镜像欢迎声明的 namespace、确认字段、版本和被断言的中文文案。 -有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从 +有一类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从 `@deepseek-ai/dsh-client-web` import `AppWebEntry`、从 `@deepseek-ai/dsh-client-modules/client` import boot manifest 类型:启动真实 shell 正是该 -harness 的用途,且这两个包本来就在 Host 图中。另外,chat 场景从 -`@deepseek-ai/dsh-client-runtime/client` import `conversationContextKey`,因为 -`client/runtime` 经未拆分的 `directory-picker` 包可达,且不会再牵入别的东西。这种可达性是 -偶然而非保证——一旦它离开该图,就像其余情形那样镜像该 helper。 +harness 的用途,且这两个包本来就在 Host 图中。chat 场景则在 `support.ts` 中镜像 +`conversationContextKey`,而不 import 其 Client owner。 没有任何机制强制这条规则;靠 review 守住它。 diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 04eb232c7d..d73ec11dc3 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -259,17 +259,18 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.waitFor({ state: 'detached', timeout: 10_000 }) await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 }) await expect.poll(async () => { - const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { + const response = await fetch(`${scaffold.baseUrl}/api/session/list`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {}, + type: 'client-request', rpcId: 'creator-draft-stage', method: 'session/list', + payload: { args: { _request: {} } }, }), }) const body = await response.json() as { - result: { value?: { sessions: unknown[] } } + result: { value?: { items: unknown[] } } } - return JSON.stringify(body.result.value?.sessions ?? body.result) + return JSON.stringify(body.result.value?.items ?? body.result) }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"') }, 60_000) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 3d7c10abbc..071a7602a5 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -145,11 +145,12 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise * @returns the live session's preset, or undefined before it is listed. */ async function livePreset(baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/api/session.list`, { + const response = await fetch(`${baseUrl}/api/session/list`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {}, + type: 'client-request', rpcId: 'agent-preset-live', method: 'session/list', + payload: { args: { _request: {} } }, }), }) const body = await response.json() as { diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 981836775c..10264b7103 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -22,6 +22,11 @@ interface AssembledPlugin extends WebBootEntry { bundlePath: string } +interface AssembledBootOptions { + /** Package ids omitted from this mounted composition. */ + readonly exclude?: readonly string[] +} + interface ClientPackageManifest { name?: string exports?: Record @@ -187,24 +192,25 @@ export function installAssembledBootEnv(): void { * Mount the assembled application on the fixture transport; the teardown * registered by installAssembledBootEnv disposes it. * @param search - fixture query string used to select deterministic host behavior. + * @param options - composition changes applied to this mount. */ -export function mountAssembledApp(search = '?fixture'): void { +export function mountAssembledApp(search = '?fixture', options: AssembledBootOptions = {}): void { + const excluded = new Set(options.exclude) + const plugins = PLUGINS.filter(plugin => !excluded.has(plugin.id)) history.replaceState(null, '', `/${search}`) const root = document.createElement('div') root.id = 'root' document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) } + win.__DSH_BOOT__ = { rev: 'fx', entries: plugins.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) } const [facadeRow] = bootInjections(win.__DSH_BOOT__) if (facadeRow?.kind !== 'script') throw new Error('missing injected ModuleLoader facade row') ;(0, eval)(facadeRow.text) - // Mirror the blocking Host-injected scripts before the Vite entry calls create(). - for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) { - const plugin = PLUGINS.find(candidate => candidate.id === id) - if (plugin === undefined) throw new Error(`missing parser-preloaded fixture row ${id}`) - const code = bundles.get(plugin.url) - if (code === undefined) throw new Error(`missing built bundle ${plugin.url}`) - ;(0, eval)(code) - } + // Mirror the blocking Host-injected modules script before the Vite entry calls create(). + const modules = plugins.find(candidate => candidate.id === '@deepseek-ai/dsh-client-modules') + if (modules === undefined) throw new Error('missing parser-preloaded fixture row @deepseek-ai/dsh-client-modules') + const modulesCode = bundles.get(modules.url) + if (modulesCode === undefined) throw new Error(`missing built bundle ${modules.url}`) + ;(0, eval)(modulesCode) act(() => { const entry = new AppWebEntry(root, { loadBundle: async (url) => { diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 0e48e08ec0..96a0a4549b 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -8,8 +8,9 @@ // // Component behavior remains owned by per-package suites (SlotTestRuntime // benches over src). This smoke additionally pins the resident interaction -// fixture's cross-plugin projection because only the built connection/runtime/ -// workspace graph can prove that transport-to-row path end to end. +// fixture's cross-plugin projection because only the built connection, +// Controller, UI adapter, and Workspace graph can prove that transport-to-row +// path end to end. import { resolve } from 'node:path' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { expect, it } from 'vitest' @@ -129,3 +130,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(styleOwners).toContain(plugin) } }) + +it('boots without ui-chat and does not select another conversation view implicitly', async () => { + mountAssembledApp('?fixture', { exclude: ['@deepseek-ai/dsh-client-ui-chat'] }) + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const boot = Reflect.get(window, '__DSH_BOOT__') as { entries: Array<{ id: string }> } | undefined + expect(boot?.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-chat')).toBe(false) + const sessionTitle = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(sessionTitle) + await waitFor(() => { + expect(document.querySelector('[data-slot="conversation.session"]')).not.toBeNull() + }, { timeout: 10_000 }) + expect(document.querySelector('[data-slot="conversation.view"]')).toBeNull() +}) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 2e1892f8b6..7c70741296 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -30,6 +30,7 @@ const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e' const REPLAY_CONTEXT_WINDOW = 10_000_000 const STREAM_PACE_MS = 24 const GEOMETRY_TOLERANCE = 2 +const RESPONSIVE_REFLOW_TOLERANCE = 32 const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.' const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST' const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE' @@ -395,11 +396,15 @@ function flowTop(page: Page, key: string): Promise { }, key) } -async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise { +async function expectSameFlowTop( + page: Page, + anchor: FlowAnchor, + tolerance = GEOMETRY_TOLERANCE, +): Promise { await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), { timeout: 10_000, message: `flow row ${anchor.key} moved relative to the transcript viewport`, - }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + }).toBeLessThanOrEqual(tolerance) } async function expectBottom(page: Page): Promise { @@ -482,12 +487,13 @@ describe('web e2e: long Chat scroll contract', () => { let releaseGate: (() => void) | undefined const gate = new Promise((resolve) => { releaseGate = resolve }) releaseHistory = () => { releaseGate?.() } - await world.page.route('**/api/session.history', async (route) => { + await world.page.route('**/api/session/page', async (route) => { const request = route.request().postDataJSON() as { method?: string - payload?: { beforeSeq?: number } + payload?: { args?: { request?: { beforeSeq?: number } } } } - if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) { + if (!held && request.method === 'session/page' + && request.payload?.args?.request?.beforeSeq !== undefined) { held = true await gate } @@ -524,7 +530,7 @@ describe('web e2e: long Chat scroll contract', () => { await settled await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) - await world.page.unroute('**/api/session.history') + await world.page.unroute('**/api/session/page') let additionalPages = 0 while (additionalPages < 8) { @@ -662,7 +668,8 @@ describe('web e2e: long Chat scroll contract', () => { await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click() await world.page.getByRole('tab', { name: 'Chat', exact: true }).click() await nextPaint(world.page) - await expectSameFlowTop(world.page, sessionAnchor) + await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE) + const narrowSessionAnchor = await visibleFlowAnchor(world.page) await openSeed( world.page, @@ -673,7 +680,7 @@ describe('web e2e: long Chat scroll contract', () => { world.page, RESTORE_FIXTURE_A, ) - await expectSameFlowTop(world.page, sessionAnchor) + await expectSameFlowTop(world.page, narrowSessionAnchor) const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) await backToBottom.evaluate((button) => { diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 791f8e98c1..d4393bf64b 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -39,22 +39,16 @@ describe('web e2e: the composer model switch is the default for later sessions', /** Create one session and its agent through the same wire face the browser uses. */ const createSession = async (sessionId: string): Promise => { - const response = await scaffold.ctx.apiProxy.sessions.create({ - rpcId: `default-model-create-${sessionId}` as never, - payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd }, + const response = await scaffold.ctx.sessionController.create({ + sessionId: SessionId(sessionId), + cwd: scaffold.workspaceCwd, }) - if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`) - return response.result.value.sessionId + return response.sessionId } /** The route the gateway reports for one session, through the real wire face. */ const currentOf = async (sessionId: string): Promise => { - const response = await scaffold.ctx.apiProxy.sessions.models({ - rpcId: `default-model-${sessionId}` as never, - payload: { sessionId: SessionId(sessionId) }, - }) - if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`) - return response.result.value.current + return (await scaffold.ctx.sessionController.models({ sessionId: SessionId(sessionId) })).current } beforeAll(async () => { @@ -144,15 +138,12 @@ describe('web e2e: the composer model switch is the default for later sessions', // The block is an affordance; the refusal is the Host's. A client that // never disabled anything still cannot start a turn on a dead route. - const refused = await scaffold.ctx.apiProxy.sessions.prompt({ - rpcId: 'default-model-refused' as never, - payload: { - sessionId: SessionId(await createSession('default-model-refusal')), - mode: 'queue' as const, - content: [{ type: 'text' as const, text: 'hi' }], - }, - }) - expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } }) + await expect(scaffold.ctx.sessionController.prompt({ + requestId: 'default-model-refused' as never, + sessionId: SessionId(await createSession('default-model-refusal')), + mode: 'queue', + content: [{ type: 'text', text: 'hi' }], + }, new AbortController().signal)).rejects.toMatchObject({ failure: { code: 'model-unavailable' } }) // The way out stays open. Locking the model seat with everything else // would leave the composer asking for the one thing it prevents. diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 869aac2f14..3a814878da 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -228,7 +228,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) // Selection persisted (dsh.sessions.current) and history replayed: the - // recorded turn re-renders from session.history with zero model calls — + // recorded turn re-renders from a Session Controller page with zero model calls — // the replay cursor was fully consumed before the reload, so any stray // request would fail the scenario loudly at close(). await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 1e2a0953b2..12ef148935 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -37,11 +37,10 @@ const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summ async function baselineResponse( page: Page, - method: 'session.list' | 'workspace.list', ): Promise { return page.waitForResponse(response => ( response.request().method() === 'POST' - && new URL(response.url()).pathname === `/api/${method}` + && new URL(response.url()).pathname === '/api/session/list' ), { timeout: 30_000 }) } @@ -111,19 +110,14 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { slotErrors.push(message.text()) } }) - // Initial navigation and list ownership settle only after both independent - // RPC baselines succeed; arm before navigation so neither response is missed. - const sessionBaseline = baselineResponse(page, 'session.list') - const workspaceBaseline = baselineResponse(page, 'workspace.list') - const [, sessionResponse, workspaceResponse] = await Promise.all([ + // Arm before navigation so the Session response cannot be missed. The + // Workspace stream settles through the user-visible Ungrouped barrier. + const sessionBaseline = baselineResponse(page) + const [, sessionResponse] = await Promise.all([ page.goto(scaffold.baseUrl, { waitUntil: 'load' }), sessionBaseline, - workspaceBaseline, - ]) - await Promise.all([ - assertBaselineSucceeded(sessionResponse, 'session.list'), - assertBaselineSucceeded(workspaceResponse, 'workspace.list'), ]) + await assertBaselineSucceeded(sessionResponse, 'session.list') await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // The frame mounts before the asynchronous session-list baseline lands. // Search must target the settled seeded row, not the startup input that @@ -331,17 +325,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { observerSlotErrors.push(message.text()) } }) - const observerSessionBaseline = baselineResponse(observer, 'session.list') - const observerWorkspaceBaseline = baselineResponse(observer, 'workspace.list') - const [, observerSessionResponse, observerWorkspaceResponse] = await Promise.all([ + const observerSessionBaseline = baselineResponse(observer) + const [, observerSessionResponse] = await Promise.all([ observer.goto(scaffold.baseUrl, { waitUntil: 'load' }), observerSessionBaseline, - observerWorkspaceBaseline, - ]) - await Promise.all([ - assertBaselineSucceeded(observerSessionResponse, 'observer session.list'), - assertBaselineSucceeded(observerWorkspaceResponse, 'observer workspace.list'), ]) + await assertBaselineSucceeded(observerSessionResponse, 'observer session.list') await observer.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) await ensureSeedOpen(observer) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index f55cb0e7b5..74e0a6df1d 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -153,7 +153,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think')) // Interaction over the REAL wire-delivered transcript (the fixture-client // tier pins the same gesture against FixtureApiClient; this one runs on - // mux-frame-fed state). Runs after the golden capture so the committed + // follow-stream-fed state). Runs after the golden capture so the committed // aria surface stays the untouched settled state. const think = page.getByRole('button', { name: /^Think/ }).first() expect(await think.getAttribute('aria-expanded')).toBe('false') diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 20b183556b..2b2d194657 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -1,7 +1,7 @@ // Web e2e scenario: seeded history. A recorded session seeded cold through // the REAL persistence API renders purely from the log — the surface nothing -// else covers: sidebar cold listing, the implicit resume/attach inside the -// history RPC, history-page tool views, and the client's log-ordered transcript +// else covers: sidebar cold listing, cold history paging without Agent +// activation, history-page tool views, and the client's log-ordered transcript // events — with ZERO model calls in replay (no replay fixture; a stray stream // fails loud on the open llm seam). The cold session also carries keyless // command-row surfaces: the seeded manual `/compact` lifecycle folds into its @@ -182,6 +182,7 @@ describe('web e2e: seeded history renders through cold resume', () => { let browser: Browser let page: Page let tripwire: ReturnType + let seededThroughSeq = -1 beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -201,6 +202,7 @@ describe('web e2e: seeded history renders through cold resume', () => { const meter = scaffold.ctx.get('tokenMeter') if (meter === undefined) throw new Error('seeded-history requires the host token meter') const realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter) + seededThroughSeq = parseSeedFixture(realizedWithCompaction).events.at(-1)?.seq ?? -1 await seedSession(scaffold, realizedWithCompaction, SEED_ID) } browser = await chromium.launch() @@ -232,12 +234,17 @@ describe('web e2e: seeded history renders through cold resume', () => { // injection stays silent and this block disappears (no titles/todos on // the web), while fixture-level suites stay green. Assert through the // real HTTP wire against the booted real host. - const response = await fetch(`${scaffold.baseUrl}/api/session.history`, { + const response = await fetch(`${scaffold.baseUrl}/api/session/page`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - type: 'client-request', rpcId: 'seeded-projections', method: 'session.history', - payload: { sessionId: SEED_ID }, + type: 'client-request', rpcId: 'seeded-projections', method: 'session/page', + payload: { + args: { request: { + address: { kind: 'session', sessionId: SEED_ID }, + throughSeq: seededThroughSeq, + } }, + }, }), }) expect(response.ok).toBe(true) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 61f5af88c5..d1c6ada8ba 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -505,6 +505,8 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + const preset = dialog.getByRole('button', { name: 'Standard mode' }) + await expect.poll(() => preset.isEnabled(), { timeout: 10_000 }).toBe(true) // The markup already ships `en`, so this alone cannot prove the sync ran // — the zh scenario above is the discriminating half. Asserted here too // so a future change that resolves en but writes the wrong tag is caught. diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 3ac6706a6f..cf9793e7d9 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -16,6 +16,7 @@ // sequentially in-file. import type { ChildProcess } from 'node:child_process' import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { createRequire } from 'node:module' @@ -50,25 +51,104 @@ function waitForReadyLine(child: ChildProcess): Promise { }) } -async function rpc(baseUrl: string, method: string, payload: unknown): Promise { - const response = await fetch(`${baseUrl}/api/${method}`, { +async function remoteRpc(baseUrl: string, endpoint: string, args: object): Promise { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', - rpcId: `smoke-${method}`, - method, - payload, + rpcId: `smoke-${endpoint}`, + method: endpoint, + payload: { args }, }), }) - if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`) + if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`) const body = await response.json() as { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } - if (!body.result.ok) throw new Error(`${method} failed: ${body.result.error.code}: ${body.result.error.message}`) + if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.code}: ${body.result.error.message}`) return body.result.value } +/** Read the explicit page cut from a freshly opened Session follow stream. */ +async function sessionCursor(baseUrl: string, sessionId: string): Promise { + const socket = new WebSocket(`${baseUrl.replace(/^http/, 'ws')}/api/remote.mux`) + const streamId = `smoke-history-${randomUUID()}` + try { + await new Promise((resolve, reject) => { + const cleanup = (): void => { + socket.removeEventListener('open', opened) + socket.removeEventListener('error', failed) + socket.removeEventListener('close', closed) + } + const opened = (): void => { + cleanup() + resolve() + } + const failed = (): void => { + cleanup() + reject(new Error('session/follow carrier failed before opening')) + } + const closed = (): void => { + cleanup() + reject(new Error('session/follow carrier closed before opening')) + } + socket.addEventListener('open', opened) + socket.addEventListener('error', failed) + socket.addEventListener('close', closed) + }) + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { finish(new Error('session/follow did not publish an opening cursor')) }, 10_000) + const cleanup = (): void => { + clearTimeout(timer) + socket.removeEventListener('message', message) + socket.removeEventListener('error', failed) + socket.removeEventListener('close', closed) + } + const finish = (error: Error | undefined, cursor?: number): void => { + cleanup() + if (error !== undefined) reject(error) + else resolve(cursor ?? -1) + } + const message = (event: MessageEvent): void => { + try { + if (typeof event.data !== 'string') throw new Error('session/follow published a non-text frame') + const frame: unknown = JSON.parse(event.data) + if (!isRecord(frame) || frame.streamId !== streamId) return + if (frame.type === 'error') { + finish(new Error(`session/follow failed: ${JSON.stringify(frame.error)}`)) + return + } + if (frame.type === 'end') { + finish(new Error('session/follow ended before its opening cursor')) + return + } + const value = frame.value + if (frame.type === 'item' && isRecord(value) + && value.type === 'opened' && Number.isSafeInteger(value.cursor)) { + finish(undefined, value.cursor as number) + } + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + } + const failed = (): void => { finish(new Error('session/follow carrier failed before its opening cursor')) } + const closed = (): void => { finish(new Error('session/follow carrier closed before its opening cursor')) } + socket.addEventListener('message', message) + socket.addEventListener('error', failed) + socket.addEventListener('close', closed) + socket.send(JSON.stringify({ + type: 'open', + streamId, + endpoint: 'session/follow', + payload: { args: { request: { address: { kind: 'session', sessionId } } } }, + })) + }) + } finally { + socket.close() + } +} + interface HistoryPage { events: { event: { type: string; data: unknown } }[] hasMore: boolean @@ -101,7 +181,10 @@ function hasAssistantMarker(page: HistoryPage, marker: string): boolean { } async function history(baseUrl: string, sessionId: string): Promise { - return rpc(baseUrl, 'session.history', { sessionId, maxMessages: 10 }) + const throughSeq = await sessionCursor(baseUrl, sessionId) + return remoteRpc(baseUrl, 'session/page', { + request: { address: { kind: 'session', sessionId }, throughSeq, maxMessages: 10 }, + }) } async function waitForProviderTitle(baseUrl: string, sessionId: string): Promise { @@ -142,8 +225,8 @@ async function detailsTrack(page: Page): Promise { // plugin's client bundle exists and exports apply, the loader fail-louds and // the frame never appears. const UI_PLUGIN_DIRS = [ - 'connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', - 'ui-settings', 'ui-settings-general', 'ui-settings-models', 'ui-conversation', + 'connection', 'ui-theme', 'locale', 'ui-layout', 'ui-renderer', 'ui-session', 'ui-sidebar', + 'ui-settings', 'ui-settings-general', 'ui-settings-models', 'ui-conversation', 'ui-approval', 'ui-chat', 'ui-model-selection', 'ui-user-questions', 'ui-trajectory', '../session-query/session-log-export', ] const ROUND_DONE_MARKER = 'WEB_ROUND_DONE' @@ -242,12 +325,13 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) - await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} }) + await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: { + requestId: randomUUID(), sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: 'go' }], - }) + } }) const capturedRequests = await Promise.race([ providerRequests, new Promise((_resolve, reject) => { @@ -354,12 +438,13 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) - await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} }) + await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: { + requestId: randomUUID(), sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: promptMarker }], - }) + } }) let page: HistoryPage | undefined await expect.poll(async () => { page = await history(baseUrl, created.sessionId) @@ -385,7 +470,7 @@ describe('dsh web keyless CLI smoke', () => { await new Promise(resolveClose => provider.close(() => { resolveClose() })) rmSync(workspace, { recursive: true, force: true }) } - }, 30_000) + }, 120_000) it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => { requireDist() @@ -438,12 +523,13 @@ describe('dsh web keyless CLI smoke', () => { ) try { const baseUrl = await waitForReadyLine(child) - const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) - await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} }) + await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: { + requestId: randomUUID(), sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: 'go' }], - }) + } }) const captured = await Promise.race([ providerRequest, new Promise((_resolve, reject) => { @@ -555,10 +641,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke productTitle, { timeout: 15_000 }, ) - await expect.poll(async () => (await rpc<{ items: { sessionId: string }[] }>(baseUrl, 'session.list', {})).items.length, { + await expect.poll(async () => (await remoteRpc<{ items: { sessionId: string }[] }>( + baseUrl, + 'session/list', + { _request: {} }, + )).items.length, { timeout: 15_000, }).toBe(1) - const sessions = await rpc<{ items: { sessionId: string }[] }>(baseUrl, 'session.list', {}) + const sessions = await remoteRpc<{ items: { sessionId: string }[] }>( + baseUrl, + 'session/list', + { _request: {} }, + ) const sessionId = sessions.items[0]?.sessionId if (sessionId === undefined) throw new Error('created Web session was not listed') const durableTitle = await waitForProviderTitle(baseUrl, sessionId) diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index b39e720600..39cfece887 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -5,7 +5,7 @@ // workspace and opens its blank session. `openState` flips to `loading` the // moment `open()` lands; driving `data-phase=settling` on the conversation // root from that flip would hide the composer seat and the header -// (`visibility:hidden`) for the whole `session.history` round-trip — the +// (`visibility:hidden`) for the whole `session.page` round-trip — the // center column blanks and repaints like a full-page refresh on every launch. // // The unit spec pins the phase condition over hand-built stores. What only the @@ -17,7 +17,7 @@ // replacing those nodes. // // The round-trip against a loopback host is far too fast to observe, so this -// scenario HOLDS the `session.history` response open in the browser's network +// scenario HOLDS the `session.page` response open in the browser's network // handler and asserts the visible frame while it is in flight. That wait is // what makes the assertions non-vacuous: without the phase exemption, the held // window is exactly when `settling` would be painted and the composer hidden. @@ -31,8 +31,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' -/** Wire path of the history round-trip the conversation root waits out (POST /api/session.history). */ -const HISTORY_ROUTE = '**/api/session.history' +/** Wire path of the history round-trip the conversation root waits out. */ +const HISTORY_ROUTE = '**/api/session/page' /** * The conversation root's own phase attribute. `div` disambiguates it from the diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 43fccd65b5..8305771c4c 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -19,7 +19,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // Two goldens pin the transient Host projection and its durable handoff: the -// mid-turn state renders accepted steering from session/queue while the +// mid-turn state renders accepted steering from the Session control queue while the // question blocks admission, then the settled state renders the same message // from user/message beside the reply that obeys it. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index eb5b364628..193349fff5 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -219,7 +219,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, ]) // These two cold fixtures were authored after the page's initial - // session.list and intentionally emitted no session-added frame. Reload + // session.list and intentionally emitted no api-session/added event. Reload // to exercise the restart baseline that discovers their full lineage. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -352,7 +352,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) }) - it('continues through FIFO follow-up admission and receives the child mux events', async () => { + it('continues through FIFO follow-up admission and receives the child follow events', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup')) const ended = new Promise((resolveEnded, reject) => { const timer = setTimeout(() => { @@ -451,7 +451,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() const forkResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/session.fork') + new URL(response.url()).pathname === '/api/session/fork') await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } } expect(forkReceipt.result).toMatchObject({ ok: true }) @@ -479,7 +479,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(childId)).toBeUndefined() const forkResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/session.fork') + new URL(response.url()).pathname === '/api/session/fork') await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() const forkReceipt = await (await forkResponse).json() as { result: { ok: true; value: { sessionId: string } } | { ok: false } @@ -490,7 +490,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined() await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() - await page.getByRole('button', { name: '3 subagents' }).hover() + await page.getByRole('button', { name: '3 subagents' }).press('ArrowDown') await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() const input = page.locator('textarea:enabled').first() await input.waitFor() diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 810fe4bcf1..592d55abe6 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -232,7 +232,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co expect(((await (await interruptResponse).json()) as { result: { ok: boolean; value?: { accepted: boolean } } }).result).toMatchObject({ ok: true, value: { accepted: true } }) - expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([]) + expect(apiCalls.filter(path => path === '/api/session/cancel')).toEqual([]) await aborted await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle') @@ -280,7 +280,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co result: { ok: boolean; value?: { accepted: boolean } } }).result).toMatchObject({ ok: true, value: { accepted: true } }) // The addressed child stops through its own RPC, never the generic one. - expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([]) + expect(apiCalls.filter(path => path === '/api/session/cancel')).toEqual([]) await aborted // Parked: the Activation stays resident and idle with the retained diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 757c695408..b3b9a53037 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -22,7 +22,7 @@ const WAKING = 'And add one concrete example.' type RpcResult = { ok: true; value: T } | { ok: false; error: { code: string; message: string } } -/** POST one unary RPC through the real HTTP carrier and unwrap its result. */ +/** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */ async function rpc(baseUrl: string, method: string, payload: unknown): Promise> { const response = await fetch(`${baseUrl}/api/${method}`, { method: 'POST', @@ -38,6 +38,23 @@ async function rpc(baseUrl: string, method: string, payload: unknown): Promis return (await response.json() as { result: RpcResult }).result } +/** POST one generated Session Remote unary through the API Gateway carrier. */ +async function sessionRemote(baseUrl: string, method: string, request: unknown): Promise> { + const endpoint = `session/${method}` + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: `interrupt-e2e-${endpoint}-${randomUUID()}`, + method: endpoint, + payload: { args: { request } }, + }), + }) + if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`) + return (await response.json() as { result: RpcResult }).result +} + /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs @@ -91,7 +108,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co }) // A live parent Agent through the real API; no workspace or browser. - const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', { + const created = await sessionRemote<{ sessionId: string }>(scaffold.baseUrl, 'create', { cwd: scaffold.workspaceCwd, }) if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 3a1cd94782..ec3d0eea77 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -125,7 +125,7 @@ export async function saveFailureShot(page: Page, name: string): Promise { * The conversation engine's Context key format, restated here rather than * imported: these specs live in the Host compiler aggregate, which must not * reach the Client plane. The engine's own copy is - * `conversationContextKey` in dsh-client-runtime; a drift between them makes + * `conversationContextKey` in ui-conversation; a drift between them makes * the key miss its rendered node, so the assertion fails loudly. * @param kind - Definition kind. * @param id - Definition-local business identity. diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index 16880295fd..d3f88d3a7f 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -218,12 +218,13 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { let finishHeldRequest: () => void = () => {} const gate = new Promise((resolve) => { releaseHistory = resolve }) const heldRequestFinished = new Promise((resolve) => { finishHeldRequest = resolve }) - await page.route('**/api/session.history', async (route) => { + await page.route('**/api/session/page', async (route) => { const request = route.request().postDataJSON() as { method?: string - payload?: { beforeSeq?: number } + payload?: { args?: { request?: { beforeSeq?: number } } } } - if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) { + if (!held && request.method === 'session/page' + && request.payload?.args?.request?.beforeSeq !== undefined) { held = true await gate try { @@ -340,7 +341,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => { } finally { releaseHistory() if (held) await heldRequestFinished - await page.unroute('**/api/session.history') + await page.unroute('**/api/session/page') } }, 180_000) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 20d8887131..386615f059 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -104,6 +104,9 @@ "tests/workflow-run.e2e.ts" ], "references": [ + { + "path": "../../packages/client/store" + }, { "path": "../../packages/client/web" }, diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 5c6e297fc2..bdf326f8db 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: cd3103a172d75a4ab325a2368e37af354f09051b -api-gateway.zh.md: fd7494917f209af4a16f88b87afbc47d75c6afd3 +api-gateway.md: 60b9893675ad965c3f88677eac32352acfffeb31 +api-gateway.zh.md: fc217ce3a976fd8cf045848aa331c2115c3a4d65 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index cd3103a172..60b9893675 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -59,7 +59,7 @@ The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. ```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { AgentContext } from '@deepseek-ai/dsh-api-session-controller/client' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index fd7494917f..fc217ce3a9 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -59,7 +59,7 @@ Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直 ```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { AgentContext } from '@deepseek-ai/dsh-api-session-controller/client' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index b73730d7e5..406b4015a0 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 16185120530cc6fca607aed5c66502c1122e18fb -capability-seams.zh.md: d6c41023887c66c87cfd4fb65b007732b8b2e330 +capability-seams.md: 75f050f329e709e5c88bffbe0d3bc2072d4286de +capability-seams.zh.md: 25fa48c67e406b03677debba44eff5d49fd3c626 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1618512053..75f050f329 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -35,6 +35,11 @@ flowchart LR pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] + pkg_api_session_controller["api-session-controller"] + svc_sessionController["ctx.sessionController
Host Session Remote controller"] + pkg_apiproxy["apiproxy"] + pkg_api_workspace_controller["api-workspace-controller"] + svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -51,7 +56,6 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_file["settings-file"] - pkg_apiproxy["apiproxy"] pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] @@ -215,6 +219,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams pkg_api_gateway --> svc_typertGateway + pkg_api_session_controller --> svc_sessionController + pkg_api_workspace_controller --> svc_workspaceController pkg_apiproxy --> svc_apiProxy pkg_approval --> svc_approval pkg_attachment --> svc_attachments @@ -359,6 +365,7 @@ flowchart LR svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox svc_sandboxPolicy --> pkg_terminal_bash + svc_sessionController --> pkg_apiproxy svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude_code svc_sessionPersistence --> pkg_hooks_codex @@ -444,6 +451,8 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | +| `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index d6c4102388..25fa48c67e 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -37,6 +37,11 @@ flowchart LR pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] + pkg_api_session_controller["api-session-controller"] + svc_sessionController["ctx.sessionController
Host Session Remote controller"] + pkg_apiproxy["apiproxy"] + pkg_api_workspace_controller["api-workspace-controller"] + svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -53,7 +58,6 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_file["settings-file"] - pkg_apiproxy["apiproxy"] pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] @@ -217,6 +221,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams pkg_api_gateway --> svc_typertGateway + pkg_api_session_controller --> svc_sessionController + pkg_api_workspace_controller --> svc_workspaceController pkg_apiproxy --> svc_apiProxy pkg_approval --> svc_approval pkg_attachment --> svc_attachments @@ -361,6 +367,7 @@ flowchart LR svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox svc_sandboxPolicy --> pkg_terminal_bash + svc_sessionController --> pkg_apiproxy svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude_code svc_sessionPersistence --> pkg_hooks_codex @@ -446,6 +453,8 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | +| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | +| `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index de1bcba65f..c9e8aa0367 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 9a1eda647e8643543d4ab23c877267ad06c445f5 -config-catalog.zh.md: ac0b8255a71c62770441c77b890eade0e051d561 +config-catalog.md: 7636b1e3c7933f6d70a8e40d761b3617e746c13d +config-catalog.zh.md: 4567f09059a05d3d87f336caa55fa7db831145af diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9a1eda647e..7636b1e3c7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -265,6 +265,22 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-session-controller` + +Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `workspaceRegistry` + +```ts config-catalog +/** Session Controller deployment policy. */ +export interface Config { + /** Maximum cold Session artifact size read to determine blankness. */ + readonly coldBlankProbeMaxBytes?: number +} +``` + +Source: [`packages/api/session-controller/src/index.ts:58`](../packages/api/session-controller/src/index.ts) + ## `@deepseek-ai/dsh-attachment-local` @@ -365,7 +381,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:50`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts) @@ -742,7 +758,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` +Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController` ```ts config-catalog /** Gateway plugin configuration. */ @@ -761,12 +777,6 @@ export interface Config { * @default 6 */ sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - /** - * Maximum physical size of a cold Session artifact eligible for blankness - * verification. Zero disables probes. - * @default 1024 - */ - coldBlankProbeMaxBytes?: number } ``` @@ -3018,7 +3028,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/interaction/user-approval/src/index.ts:177`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts) @@ -3229,14 +3239,16 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) -- `@deepseek-ai/dsh-api-remotes` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) +- `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) +- `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) - `@deepseek-ai/dsh-client-ui-brand-official` ([`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-chat` ([`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts)) - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) @@ -3253,6 +3265,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer` ([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-session` ([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) @@ -3333,6 +3346,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base` ([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-client-store` ([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ac0b8255a7..4567f09059 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -267,6 +267,22 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-session-controller` + +需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `workspaceRegistry` + +```ts config-catalog +/** Session Controller deployment policy. */ +export interface Config { + /** Maximum cold Session artifact size read to determine blankness. */ + readonly coldBlankProbeMaxBytes?: number +} +``` + +来源:[`packages/api/session-controller/src/index.ts:58`](../packages/api/session-controller/src/index.ts) + ## `@deepseek-ai/dsh-attachment-local` @@ -367,7 +383,7 @@ export interface ConnectionConfig { } ``` -来源:[`packages/client/connection/src/index.ts:50`](../packages/client/connection/src/index.ts) +来源:[`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts) @@ -744,7 +760,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-apiproxy` -需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` +需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController` ```ts config-catalog /** Gateway plugin configuration. */ @@ -763,16 +779,10 @@ export interface Config { * @default 6 */ sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - /** - * Maximum physical size of a cold Session artifact eligible for blankness - * verification. Zero disables probes. - * @default 1024 - */ - coldBlankProbeMaxBytes?: number } ``` -来源:[`packages/host/apiproxy/src/index.ts:41`](../packages/host/apiproxy/src/index.ts) +来源:[`packages/host/apiproxy/src/index.ts:42`](../packages/host/apiproxy/src/index.ts) @@ -3020,7 +3030,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -来源:[`packages/interaction/user-approval/src/index.ts:177`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts) @@ -3231,14 +3241,16 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) -- `@deepseek-ai/dsh-api-remotes`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) +- `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) +- `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-runtime`([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) - `@deepseek-ai/dsh-client-ui-brand-official`([`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-chat`([`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts)) - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) @@ -3255,6 +3267,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-reference`([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer`([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-session`([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) @@ -3334,6 +3347,7 @@ export interface Config { - `@deepseek-ai/dsh-atomic-write`([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts)) - `@deepseek-ai/dsh-base`([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts)) - `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-client-store`([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime`([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives`([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots`([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) diff --git a/docs/cookbook/adding-a-conversation-node.i18n.yaml b/docs/cookbook/adding-a-conversation-node.i18n.yaml index e06b41a788..e51fcf65c5 100644 --- a/docs/cookbook/adding-a-conversation-node.i18n.yaml +++ b/docs/cookbook/adding-a-conversation-node.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-conversation-node.md -adding-a-conversation-node.md: c1965dc8a3081eebb8c1026ac53d2f7b8964edb7 -adding-a-conversation-node.zh.md: 2986f695b351cd17637d7ff99112c38042950692 +adding-a-conversation-node.md: daa86f90473cb7023a21e1cfa25339fcd79aa558 +adding-a-conversation-node.zh.md: 8c6360a99acb49aebadf9a28f440853b18514b9a diff --git a/docs/cookbook/adding-a-conversation-node.md b/docs/cookbook/adding-a-conversation-node.md index c1965dc8a3..daa86f9047 100644 --- a/docs/cookbook/adding-a-conversation-node.md +++ b/docs/cookbook/adding-a-conversation-node.md @@ -28,12 +28,13 @@ The example keeps the producer declarations and client contribution in one block ```ts ignore-check import { createElement } from 'react' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { Branded } from '@deepseek-ai/dsh-brand' import type { - ClientContext, ConversationLocation, ConversationNodeContext, + ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-chat/client' type ReviewId = Branded<'ReviewId'> @@ -94,7 +95,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { } } -declare module '@deepseek-ai/dsh-client-runtime/client' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationStepDataMap { 'review-job': ReviewChatData } @@ -182,10 +183,10 @@ function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) { return createElement('p', null, text) } -export const inject = ['conversationEvents', 'slots'] +export const inject = ['uiConversation', 'slots'] export function apply(ctx: ClientContext): void { - ctx.conversationEvents.register(reviewDefinition) + ctx.uiConversation.events.register(reviewDefinition) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'review-job', @@ -230,4 +231,4 @@ Add focused tests that establish these outcomes: 5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested. 6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes. -Use [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node. +Use [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node. diff --git a/docs/cookbook/adding-a-conversation-node.zh.md b/docs/cookbook/adding-a-conversation-node.zh.md index 2986f695b3..8c6360a99a 100644 --- a/docs/cookbook/adding-a-conversation-node.zh.md +++ b/docs/cookbook/adding-a-conversation-node.zh.md @@ -28,12 +28,13 @@ ```ts ignore-check import { createElement } from 'react' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { Branded } from '@deepseek-ai/dsh-brand' import type { - ClientContext, ConversationLocation, ConversationNodeContext, + ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-chat/client' type ReviewId = Branded<'ReviewId'> @@ -94,7 +95,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { } } -declare module '@deepseek-ai/dsh-client-runtime/client' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationStepDataMap { 'review-job': ReviewChatData } @@ -182,10 +183,10 @@ function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) { return createElement('p', null, text) } -export const inject = ['conversationEvents', 'slots'] +export const inject = ['uiConversation', 'slots'] export function apply(ctx: ClientContext): void { - ctx.conversationEvents.register(reviewDefinition) + ctx.uiConversation.events.register(reviewDefinition) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'review-job', @@ -230,4 +231,4 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的 5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。 6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。 -流式与中断处理可参考 [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。 +流式与中断处理可参考 [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。 diff --git a/docs/cookbook/adding-a-settings-card.i18n.yaml b/docs/cookbook/adding-a-settings-card.i18n.yaml index 1ebdaf5789..d4411c819b 100644 --- a/docs/cookbook/adding-a-settings-card.i18n.yaml +++ b/docs/cookbook/adding-a-settings-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-settings-card.md -adding-a-settings-card.md: 8fc0f63dfaa932adba25caf1504cfd8cf28c7460 -adding-a-settings-card.zh.md: 956af879b1c1fcbb018b9420f31b2ae11244b415 +adding-a-settings-card.md: 6035cc3c586cd319c89fad95c12610d35742708c +adding-a-settings-card.zh.md: 79a4372c24f53e3d31b165d9300af591987da1f6 diff --git a/docs/cookbook/adding-a-settings-card.md b/docs/cookbook/adding-a-settings-card.md index 8fc0f63dfa..6035cc3c58 100644 --- a/docs/cookbook/adding-a-settings-card.md +++ b/docs/cookbook/adding-a-settings-card.md @@ -48,7 +48,7 @@ export function apply(ctx: Context, config: Config) { The card registers into `settings.plugin.item` under its namespace and owns everything inside it — chrome, controls, and copy. It reads and writes through `ctx.settingsScope`, which fences each write with the revision it read: ```ts ignore-check -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: the keyed slot's declaration. Cross-plugin collaboration goes // through cordis services; a value import fails the client bundle-purity gate. import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' diff --git a/docs/cookbook/adding-a-settings-card.zh.md b/docs/cookbook/adding-a-settings-card.zh.md index 956af879b1..79a4372c24 100644 --- a/docs/cookbook/adding-a-settings-card.zh.md +++ b/docs/cookbook/adding-a-settings-card.zh.md @@ -48,7 +48,7 @@ export function apply(ctx: Context, config: Config) { 卡片以自己的命名空间为键注册进 `settings.plugin.item`,并拥有其中的一切——外观、控件与文案。它通过 `ctx.settingsScope` 读写,后者用读取时的 revision 为每次写入设栅: ```ts ignore-check -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: the keyed slot's declaration. Cross-plugin collaboration goes // through cordis services; a value import fails the client bundle-purity gate. import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index d0e127d480..32599e0510 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: a9cfc1201c7d851405c99fce2e8177297f0cfe5f -event-producer-consumer.zh.md: ecccc50fc9b3cd2fd730e30ec2636c773334b00d +event-producer-consumer.md: 8cf11d8be322686c89f8bc57c9f0bc2c4a3aeb74 +event-producer-consumer.zh.md: 6b79a0fded3b5fa6956e9d7b047e78b68ded1f48 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a9cfc1201c..8cf11d8be3 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,10 +8,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team` | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team` | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | @@ -19,32 +19,37 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../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:278`](../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) | -| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:442`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:448`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:455`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | -| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | -| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | +| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | +| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | @@ -59,6 +64,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ecccc50fc9..6b79a0fded 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,10 +10,10 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team` | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team` | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | @@ -21,32 +21,37 @@ | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../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:278`](../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) | -| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:442`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:448`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:455`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | -| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | -| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | +| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | -| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | +| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | +| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | @@ -61,6 +66,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 0ad2b8d99c..6280fbe004 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 3836bd415d75aba666442a559add2e9a587dd29f -module-graph.zh.md: a1207017f705a787c64067215ae1824b88eee861 +module-graph.md: 9bbbb81dc1e5b4aa65619702d380eeb126cfdea3 +module-graph.zh.md: 86298ce1ed68aa550f5b0b489ee6dccd49771638 diff --git a/docs/module-graph.md b/docs/module-graph.md index 3836bd415d..9bbbb81dc1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -106,6 +106,8 @@ flowchart TD subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] + pkg_api_session_controller["api-session-controller"] + pkg_api_workspace_controller["api-workspace-controller"] end subgraph group_attachment["packages/attachment"] pkg_attachment["attachment"] @@ -127,10 +129,12 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] - pkg_client_runtime["client-runtime"] + pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_brand_official["client-ui-brand-official"] + pkg_client_ui_chat["client-ui-chat"] pkg_client_ui_commands["client-ui-commands"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -147,6 +151,7 @@ flowchart TD pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] + pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] @@ -360,7 +365,9 @@ flowchart TD pkg_acp_app --> pkg_invariants pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants + pkg_client_store --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants + pkg_client_ui_renderer --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_web --> pkg_invariants pkg_code_runtime --> pkg_invariants @@ -576,6 +583,7 @@ flowchart TD pkg_user_questions --> pkg_agent pkg_user_questions --> pkg_invariants pkg_user_questions --> pkg_llm + pkg_user_questions --> pkg_scope pkg_jobs --> pkg_agent pkg_jobs --> pkg_brand pkg_jobs --> pkg_invariants @@ -698,6 +706,8 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_session_telemetry + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -860,6 +870,10 @@ flowchart TD pkg_file_reference_local --> pkg_invariants pkg_file_reference_local --> pkg_system_prompt pkg_file_reference_local --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -1059,6 +1073,15 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_tool_todo + pkg_client_connection --> pkg_tools pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1079,9 +1102,6 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_cordis_host_runner - pkg_host_apiproxy --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1147,15 +1167,11 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_tool_todo - pkg_client_connection --> pkg_tools + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1200,10 +1216,6 @@ flowchart TD pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1223,12 +1235,39 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry - pkg_api_remotes --> pkg_agent + pkg_api_session_controller --> pkg_agent + pkg_api_session_controller --> pkg_agent_default_model + pkg_api_session_controller --> pkg_agent_presets + pkg_api_session_controller --> pkg_api_gateway + pkg_api_session_controller --> pkg_attachment + pkg_api_session_controller --> pkg_brand + pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_invariants + pkg_api_session_controller --> pkg_jobs + pkg_api_session_controller --> pkg_llm + pkg_api_session_controller --> pkg_scope + pkg_api_session_controller --> pkg_session + pkg_api_session_controller --> pkg_session_persistence + pkg_api_session_controller --> pkg_session_projection + pkg_api_session_controller --> pkg_session_projection_cache + pkg_api_session_controller --> pkg_session_query + pkg_api_session_controller --> pkg_session_title + pkg_api_session_controller --> pkg_subagent + pkg_api_session_controller --> pkg_tools + pkg_api_session_controller --> pkg_typert_protocol + pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_workspace + pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_invariants + pkg_api_workspace_controller --> pkg_session + pkg_api_workspace_controller --> pkg_storage_domain + pkg_api_workspace_controller --> pkg_typert_protocol + pkg_api_workspace_controller --> pkg_workspace pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway + pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_workspace_controller pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials @@ -1239,270 +1278,356 @@ flowchart TD pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session - pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings - pkg_api_remotes --> pkg_typert_registry - pkg_client_runtime --> pkg_agent - pkg_client_runtime --> pkg_api_remotes - pkg_client_runtime --> pkg_attachment - pkg_client_runtime --> pkg_client_connection - pkg_client_runtime --> pkg_commands - pkg_client_runtime --> pkg_host_apiproxy - pkg_client_runtime --> pkg_invariants - pkg_client_runtime --> pkg_llm - pkg_client_runtime --> pkg_llm_retry - pkg_client_runtime --> pkg_session - pkg_client_runtime --> pkg_session_projection - pkg_client_runtime --> pkg_session_title - pkg_client_runtime --> pkg_tool_todo - pkg_client_runtime --> pkg_tools - pkg_client_runtime --> pkg_typert_protocol - pkg_client_runtime --> pkg_typert_registry - pkg_client_ui_renderer --> pkg_client_runtime - pkg_client_ui_renderer --> pkg_invariants + pkg_api_remotes --> pkg_user_approval + pkg_api_remotes --> pkg_user_questions + pkg_client_ui_session --> pkg_api_session_controller + pkg_client_ui_session --> pkg_client_ui_renderer + pkg_client_ui_session --> pkg_invariants + pkg_client_ui_session --> pkg_session pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection - pkg_client_ui_settings --> pkg_client_runtime pkg_client_ui_settings --> pkg_invariants pkg_client_ui_settings --> pkg_settings pkg_client_locale --> pkg_api_remotes pkg_client_locale --> pkg_client_connection - pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_renderer pkg_client_locale --> pkg_client_ui_settings pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_renderer - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_input_trigger --> pkg_client_locale - pkg_client_ui_input_trigger --> pkg_client_runtime - pkg_client_ui_input_trigger --> pkg_file_reference - pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_connection pkg_client_ui_settings_models --> pkg_client_locale - pkg_client_ui_settings_models --> pkg_client_runtime + pkg_client_ui_settings_models --> pkg_client_ui_renderer pkg_client_ui_settings_models --> pkg_client_ui_settings pkg_client_ui_settings_models --> pkg_invariants pkg_client_ui_settings_plugin_inventory --> pkg_api_remotes pkg_client_ui_settings_plugin_inventory --> pkg_client_locale - pkg_client_ui_settings_plugin_inventory --> pkg_client_runtime + pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_renderer pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale - pkg_client_ui_settings_plugins --> pkg_client_runtime + pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings pkg_client_ui_settings_plugins --> pkg_invariants pkg_client_ui_theme --> pkg_api_remotes pkg_client_ui_theme --> pkg_client_connection pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_renderer pkg_client_ui_theme --> pkg_client_ui_settings pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_theme --> pkg_settings - pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_renderer + pkg_client_ui_layout --> pkg_client_ui_session pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_cordis_client_runner --> pkg_api_remotes + pkg_cordis_client_runner --> pkg_client_connection + pkg_cordis_client_runner --> pkg_client_modules + pkg_cordis_client_runner --> pkg_client_ui_renderer + pkg_cordis_client_runner --> pkg_client_ui_theme + pkg_cordis_client_runner --> pkg_invariants + pkg_client_ui_conversation --> pkg_api_remotes + pkg_client_ui_conversation --> pkg_api_session_controller + pkg_client_ui_conversation --> pkg_api_workspace_controller + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_ui_layout + pkg_client_ui_conversation --> pkg_client_ui_renderer + pkg_client_ui_conversation --> pkg_client_ui_session + pkg_client_ui_conversation --> pkg_client_ui_settings + pkg_client_ui_conversation --> pkg_client_ui_workspace + pkg_client_ui_conversation --> pkg_commands + pkg_client_ui_conversation --> pkg_goal + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm + pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_permission_presets + pkg_client_ui_conversation --> pkg_plan_mode + pkg_client_ui_conversation --> pkg_session + pkg_client_ui_conversation --> pkg_settings + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tool_todo + pkg_client_ui_conversation --> pkg_util_crypto + pkg_client_ui_conversation --> pkg_workspace + pkg_client_ui_sidebar --> pkg_api_workspace_controller + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_ui_layout + pkg_client_ui_sidebar --> pkg_client_ui_renderer + pkg_client_ui_sidebar --> pkg_client_ui_session + pkg_client_ui_sidebar --> pkg_client_ui_workspace + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_session_controller + pkg_client_ui_workspace --> pkg_api_workspace_controller + pkg_client_ui_workspace --> pkg_client_connection + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_ui_conversation + pkg_client_ui_workspace --> pkg_client_ui_renderer + pkg_client_ui_workspace --> pkg_client_ui_session + pkg_client_ui_workspace --> pkg_client_ui_sidebar + pkg_client_ui_workspace --> pkg_invariants + pkg_client_ui_workspace --> pkg_session + pkg_client_ui_agent_preset --> pkg_api_remotes + pkg_client_ui_agent_preset --> pkg_api_session_controller + pkg_client_ui_agent_preset --> pkg_client_connection + pkg_client_ui_agent_preset --> pkg_client_locale + pkg_client_ui_agent_preset --> pkg_client_ui_conversation + pkg_client_ui_agent_preset --> pkg_client_ui_renderer + pkg_client_ui_agent_preset --> pkg_client_ui_session + pkg_client_ui_agent_preset --> pkg_client_ui_settings + pkg_client_ui_agent_preset --> pkg_client_ui_workspace + pkg_client_ui_agent_preset --> pkg_invariants + pkg_client_ui_agent_preset --> pkg_session + pkg_client_ui_approval --> pkg_api_remotes + pkg_client_ui_approval --> pkg_api_session_controller + pkg_client_ui_approval --> pkg_client_locale + pkg_client_ui_approval --> pkg_client_ui_conversation + pkg_client_ui_approval --> pkg_client_ui_renderer + pkg_client_ui_approval --> pkg_client_ui_session + pkg_client_ui_approval --> pkg_invariants + pkg_client_ui_approval --> pkg_llm + pkg_client_ui_approval --> pkg_session + pkg_client_ui_approval --> pkg_typert_protocol + pkg_client_ui_brand_official --> pkg_client_ui_conversation + pkg_client_ui_brand_official --> pkg_client_ui_renderer + pkg_client_ui_brand_official --> pkg_client_ui_sidebar + pkg_client_ui_brand_official --> pkg_invariants + pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_client_locale + pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer + pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace + pkg_client_ui_directory_picker_browse --> pkg_invariants + pkg_client_ui_directory_picker_native --> pkg_client_ui_renderer + pkg_client_ui_directory_picker_native --> pkg_client_ui_workspace + pkg_client_ui_directory_picker_native --> pkg_invariants + pkg_client_ui_input_trigger --> pkg_api_session_controller + pkg_client_ui_input_trigger --> pkg_client_locale + pkg_client_ui_input_trigger --> pkg_client_ui_conversation + pkg_client_ui_input_trigger --> pkg_client_ui_renderer + pkg_client_ui_input_trigger --> pkg_client_ui_session + pkg_client_ui_input_trigger --> pkg_file_reference + pkg_client_ui_input_trigger --> pkg_invariants + pkg_client_ui_input_trigger --> pkg_session + pkg_client_ui_jobs --> pkg_api_session_controller + pkg_client_ui_jobs --> pkg_client_locale + pkg_client_ui_jobs --> pkg_client_ui_conversation + pkg_client_ui_jobs --> pkg_client_ui_renderer + pkg_client_ui_jobs --> pkg_client_ui_session + pkg_client_ui_jobs --> pkg_invariants + pkg_client_ui_plan --> pkg_api_remotes + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_renderer + pkg_client_ui_plan --> pkg_client_ui_session + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_plan --> pkg_session + pkg_client_ui_settings_general --> pkg_api_remotes + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_ui_renderer + pkg_client_ui_settings_general --> pkg_client_ui_session + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_sidebar + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_settings_general --> pkg_settings + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_client_locale + pkg_client_ui_trajectory --> pkg_client_ui_conversation + pkg_client_ui_trajectory --> pkg_client_ui_renderer + pkg_client_ui_trajectory --> pkg_client_ui_session + pkg_client_ui_trajectory --> pkg_compaction + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_session + pkg_client_ui_trajectory --> pkg_tools + pkg_client_ui_user_questions --> pkg_api_remotes + pkg_client_ui_user_questions --> pkg_api_session_controller + pkg_client_ui_user_questions --> pkg_client_locale + pkg_client_ui_user_questions --> pkg_client_ui_conversation + pkg_client_ui_user_questions --> pkg_client_ui_renderer + pkg_client_ui_user_questions --> pkg_client_ui_session + pkg_client_ui_user_questions --> pkg_invariants + pkg_client_ui_user_questions --> pkg_session + pkg_client_ui_user_questions --> pkg_typert_protocol + pkg_client_ui_user_questions --> pkg_user_questions + pkg_client_ui_chat --> pkg_agent + pkg_client_ui_chat --> pkg_api_remotes + pkg_client_ui_chat --> pkg_api_session_controller + pkg_client_ui_chat --> pkg_api_workspace_controller + pkg_client_ui_chat --> pkg_attachment + pkg_client_ui_chat --> pkg_client_locale + pkg_client_ui_chat --> pkg_client_ui_approval + pkg_client_ui_chat --> pkg_client_ui_conversation + pkg_client_ui_chat --> pkg_client_ui_layout + pkg_client_ui_chat --> pkg_client_ui_renderer + pkg_client_ui_chat --> pkg_client_ui_session + pkg_client_ui_chat --> pkg_client_ui_workspace + pkg_client_ui_chat --> pkg_commands + pkg_client_ui_chat --> pkg_compaction + pkg_client_ui_chat --> pkg_invariants + pkg_client_ui_chat --> pkg_llm + pkg_client_ui_chat --> pkg_llm_retry + pkg_client_ui_chat --> pkg_session + pkg_client_ui_chat --> pkg_session_stats + pkg_client_ui_chat --> pkg_token_meter + pkg_client_ui_chat --> pkg_tools + pkg_client_ui_commands --> pkg_api_remotes + pkg_client_ui_commands --> pkg_api_session_controller + pkg_client_ui_commands --> pkg_client_locale + pkg_client_ui_commands --> pkg_client_ui_conversation + pkg_client_ui_commands --> pkg_client_ui_input_trigger + pkg_client_ui_commands --> pkg_client_ui_renderer + pkg_client_ui_commands --> pkg_client_ui_session + pkg_client_ui_commands --> pkg_commands + pkg_client_ui_commands --> pkg_invariants + pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes pkg_client_ui_reference --> pkg_client_locale - pkg_client_ui_reference --> pkg_client_runtime pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol - pkg_cordis_client_runner --> pkg_api_remotes - pkg_cordis_client_runner --> pkg_client_connection - pkg_cordis_client_runner --> pkg_client_modules - pkg_cordis_client_runner --> pkg_client_runtime - pkg_cordis_client_runner --> pkg_client_ui_theme - pkg_cordis_client_runner --> pkg_invariants - pkg_client_ui_conversation --> pkg_agent - pkg_client_ui_conversation --> pkg_api_remotes - pkg_client_ui_conversation --> pkg_attachment - pkg_client_ui_conversation --> pkg_brand - pkg_client_ui_conversation --> pkg_client_connection - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_input_trigger - pkg_client_ui_conversation --> pkg_client_ui_layout - pkg_client_ui_conversation --> pkg_client_ui_settings - pkg_client_ui_conversation --> pkg_commands - pkg_client_ui_conversation --> pkg_compaction - pkg_client_ui_conversation --> pkg_goal - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_llm_retry - pkg_client_ui_conversation --> pkg_permission_presets - pkg_client_ui_conversation --> pkg_plan_mode - pkg_client_ui_conversation --> pkg_session_stats - pkg_client_ui_conversation --> pkg_settings - pkg_client_ui_conversation --> pkg_token_meter - pkg_client_ui_conversation --> pkg_tool_todo - pkg_client_ui_conversation --> pkg_tools - pkg_client_ui_conversation --> pkg_util_crypto - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_layout - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_agent_preset --> pkg_api_remotes - pkg_client_ui_agent_preset --> pkg_client_connection - pkg_client_ui_agent_preset --> pkg_client_locale - pkg_client_ui_agent_preset --> pkg_client_runtime - pkg_client_ui_agent_preset --> pkg_client_ui_conversation - pkg_client_ui_agent_preset --> pkg_client_ui_settings - pkg_client_ui_agent_preset --> pkg_invariants - pkg_client_ui_attachment --> pkg_attachment - pkg_client_ui_attachment --> pkg_client_runtime - pkg_client_ui_attachment --> pkg_client_ui_conversation - pkg_client_ui_attachment --> pkg_invariants - pkg_client_ui_brand_official --> pkg_client_runtime - pkg_client_ui_brand_official --> pkg_client_ui_conversation - pkg_client_ui_brand_official --> pkg_client_ui_sidebar - pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_commands --> pkg_api_remotes - pkg_client_ui_commands --> pkg_client_locale - pkg_client_ui_commands --> pkg_client_runtime - pkg_client_ui_commands --> pkg_client_ui_conversation - pkg_client_ui_commands --> pkg_client_ui_input_trigger - pkg_client_ui_commands --> pkg_commands - pkg_client_ui_commands --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_connection - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_deliverables --> pkg_system_prompt - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_commands - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_goal --> pkg_session - pkg_client_ui_goal --> pkg_typert_protocol - pkg_client_ui_jobs --> pkg_client_locale - pkg_client_ui_jobs --> pkg_client_runtime - pkg_client_ui_jobs --> pkg_client_ui_conversation - pkg_client_ui_jobs --> pkg_invariants - pkg_client_ui_message_feedback --> pkg_api_remotes - pkg_client_ui_message_feedback --> pkg_client_connection - pkg_client_ui_message_feedback --> pkg_client_locale - pkg_client_ui_message_feedback --> pkg_client_runtime - pkg_client_ui_message_feedback --> pkg_client_ui_conversation - pkg_client_ui_message_feedback --> pkg_invariants - pkg_client_ui_message_feedback --> pkg_message_feedback - pkg_client_ui_message_feedback --> pkg_typert_protocol - pkg_client_ui_plan --> pkg_api_remotes - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_settings_general --> pkg_api_remotes - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_sidebar - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_settings_general --> pkg_settings + pkg_client_ui_subagent --> pkg_api_session_controller + pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation pkg_client_ui_subagent --> pkg_client_ui_input_trigger + pkg_client_ui_subagent --> pkg_client_ui_renderer + pkg_client_ui_subagent --> pkg_client_ui_session pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_session pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter - pkg_client_ui_tool --> pkg_api_remotes - pkg_client_ui_tool --> pkg_client_connection - pkg_client_ui_tool --> pkg_client_locale - pkg_client_ui_tool --> pkg_client_runtime - pkg_client_ui_tool --> pkg_client_ui_conversation - pkg_client_ui_tool --> pkg_invariants - pkg_client_ui_trajectory --> pkg_agent - pkg_client_ui_trajectory --> pkg_client_locale - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_conversation - pkg_client_ui_trajectory --> pkg_compaction - pkg_client_ui_trajectory --> pkg_invariants - pkg_client_ui_trajectory --> pkg_tools - pkg_client_ui_user_questions --> pkg_api_remotes - pkg_client_ui_user_questions --> pkg_client_locale - pkg_client_ui_user_questions --> pkg_client_runtime - pkg_client_ui_user_questions --> pkg_client_ui_conversation - pkg_client_ui_user_questions --> pkg_invariants - pkg_client_ui_workflow_run --> pkg_client_locale - pkg_client_ui_workflow_run --> pkg_client_runtime - pkg_client_ui_workflow_run --> pkg_client_ui_conversation - pkg_client_ui_workflow_run --> pkg_invariants - pkg_client_ui_workflow_run --> pkg_session - pkg_client_ui_workflow_run --> pkg_tool_workflow - pkg_client_ui_workflow_run --> pkg_workflow - pkg_client_ui_workspace --> pkg_client_connection - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_conversation - pkg_client_ui_workspace --> pkg_client_ui_sidebar - pkg_client_ui_workspace --> pkg_invariants - pkg_session_log_export --> pkg_client_locale - pkg_session_log_export --> pkg_client_runtime - pkg_session_log_export --> pkg_client_ui_commands - pkg_session_log_export --> pkg_client_ui_conversation - pkg_session_log_export --> pkg_commands - pkg_session_log_export --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_locale - pkg_client_ui_directory_picker_browse --> pkg_client_runtime - pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace - pkg_client_ui_directory_picker_browse --> pkg_invariants - pkg_client_ui_directory_picker_native --> pkg_client_runtime - pkg_client_ui_directory_picker_native --> pkg_client_ui_workspace - pkg_client_ui_directory_picker_native --> pkg_invariants - pkg_client_ui_model_selection --> pkg_api_remotes - pkg_client_ui_model_selection --> pkg_client_connection - pkg_client_ui_model_selection --> pkg_client_locale - pkg_client_ui_model_selection --> pkg_client_runtime - pkg_client_ui_model_selection --> pkg_client_ui_commands - pkg_client_ui_model_selection --> pkg_client_ui_conversation - pkg_client_ui_model_selection --> pkg_client_ui_input_trigger - pkg_client_ui_model_selection --> pkg_invariants - pkg_client_ui_permission_presets --> pkg_api_remotes - pkg_client_ui_permission_presets --> pkg_client_connection - pkg_client_ui_permission_presets --> pkg_client_locale - pkg_client_ui_permission_presets --> pkg_client_runtime - pkg_client_ui_permission_presets --> pkg_client_ui_commands - pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger - pkg_client_ui_permission_presets --> pkg_client_ui_settings - pkg_client_ui_permission_presets --> pkg_invariants - pkg_client_ui_permission_presets --> pkg_permission_presets - pkg_client_ui_skill --> pkg_api_remotes - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_input_trigger - pkg_client_ui_skill --> pkg_client_ui_tool - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_cordis --> pkg_api_remotes - pkg_client_ui_cordis --> pkg_client_connection - pkg_client_ui_cordis --> pkg_client_locale - pkg_client_ui_cordis --> pkg_client_runtime - pkg_client_ui_cordis --> pkg_client_ui_input_trigger - pkg_client_ui_cordis --> pkg_client_ui_sidebar - pkg_client_ui_cordis --> pkg_client_ui_tool - pkg_client_ui_cordis --> pkg_cordis_client_runner - pkg_client_ui_cordis --> pkg_invariants pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_client_locale + pkg_session_log_export --> pkg_client_ui_commands + pkg_session_log_export --> pkg_client_ui_conversation + pkg_session_log_export --> pkg_client_ui_renderer + pkg_session_log_export --> pkg_client_ui_session + pkg_session_log_export --> pkg_commands + pkg_session_log_export --> pkg_invariants + pkg_client_ui_attachment --> pkg_attachment + pkg_client_ui_attachment --> pkg_client_ui_chat + pkg_client_ui_attachment --> pkg_client_ui_conversation + pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_connection + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_ui_chat + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_renderer + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_deliverables --> pkg_session + pkg_client_ui_deliverables --> pkg_system_prompt + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_api_session_controller + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_ui_chat + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_renderer + pkg_client_ui_goal --> pkg_client_ui_session + pkg_client_ui_goal --> pkg_commands + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_goal --> pkg_session + pkg_client_ui_goal --> pkg_typert_protocol + pkg_client_ui_message_feedback --> pkg_api_remotes + pkg_client_ui_message_feedback --> pkg_client_connection + pkg_client_ui_message_feedback --> pkg_client_locale + pkg_client_ui_message_feedback --> pkg_client_ui_chat + pkg_client_ui_message_feedback --> pkg_client_ui_conversation + pkg_client_ui_message_feedback --> pkg_client_ui_renderer + pkg_client_ui_message_feedback --> pkg_client_ui_session + pkg_client_ui_message_feedback --> pkg_invariants + pkg_client_ui_message_feedback --> pkg_message_feedback + pkg_client_ui_message_feedback --> pkg_session + pkg_client_ui_message_feedback --> pkg_typert_protocol + pkg_client_ui_model_selection --> pkg_api_remotes + pkg_client_ui_model_selection --> pkg_api_session_controller + pkg_client_ui_model_selection --> pkg_client_connection + pkg_client_ui_model_selection --> pkg_client_locale + pkg_client_ui_model_selection --> pkg_client_ui_commands + pkg_client_ui_model_selection --> pkg_client_ui_conversation + pkg_client_ui_model_selection --> pkg_client_ui_input_trigger + pkg_client_ui_model_selection --> pkg_client_ui_renderer + pkg_client_ui_model_selection --> pkg_client_ui_session + pkg_client_ui_model_selection --> pkg_invariants + pkg_client_ui_model_selection --> pkg_session + pkg_client_ui_model_selection --> pkg_typert_protocol + pkg_client_ui_permission_presets --> pkg_api_remotes + pkg_client_ui_permission_presets --> pkg_api_session_controller + pkg_client_ui_permission_presets --> pkg_client_connection + pkg_client_ui_permission_presets --> pkg_client_locale + pkg_client_ui_permission_presets --> pkg_client_ui_commands + pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger + pkg_client_ui_permission_presets --> pkg_client_ui_renderer + pkg_client_ui_permission_presets --> pkg_client_ui_session + pkg_client_ui_permission_presets --> pkg_client_ui_settings + pkg_client_ui_permission_presets --> pkg_invariants + pkg_client_ui_permission_presets --> pkg_permission_presets + pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_api_workspace_controller + pkg_client_ui_tool --> pkg_client_connection + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_ui_chat + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_renderer + pkg_client_ui_tool --> pkg_client_ui_session + pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_api_session_controller + pkg_client_ui_workflow_run --> pkg_client_locale + pkg_client_ui_workflow_run --> pkg_client_ui_chat + pkg_client_ui_workflow_run --> pkg_client_ui_conversation + pkg_client_ui_workflow_run --> pkg_client_ui_renderer + pkg_client_ui_workflow_run --> pkg_client_ui_session + pkg_client_ui_workflow_run --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_session + pkg_client_ui_workflow_run --> pkg_tool_workflow + pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_test_runtime --> pkg_api_session_controller + pkg_client_test_runtime --> pkg_api_workspace_controller + pkg_client_test_runtime --> pkg_attachment + pkg_client_test_runtime --> pkg_client_connection + pkg_client_test_runtime --> pkg_client_store + pkg_client_test_runtime --> pkg_client_ui_chat + pkg_client_test_runtime --> pkg_client_ui_conversation + pkg_client_test_runtime --> pkg_client_ui_renderer + pkg_client_test_runtime --> pkg_client_ui_session + pkg_client_test_runtime --> pkg_client_ui_settings + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_invariants + pkg_client_test_runtime --> pkg_session + pkg_client_ui_skill --> pkg_api_remotes + pkg_client_ui_skill --> pkg_api_session_controller + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_ui_input_trigger + pkg_client_ui_skill --> pkg_client_ui_renderer + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants + pkg_client_ui_skill --> pkg_session + pkg_client_ui_cordis --> pkg_api_remotes + pkg_client_ui_cordis --> pkg_client_connection + pkg_client_ui_cordis --> pkg_client_locale + pkg_client_ui_cordis --> pkg_client_ui_input_trigger + pkg_client_ui_cordis --> pkg_client_ui_renderer + pkg_client_ui_cordis --> pkg_client_ui_session + pkg_client_ui_cordis --> pkg_client_ui_sidebar + pkg_client_ui_cordis --> pkg_client_ui_tool + pkg_client_ui_cordis --> pkg_cordis_client_runner + pkg_client_ui_cordis --> pkg_invariants ``` | Package | Group | Depends on | @@ -1522,7 +1647,9 @@ flowchart TD | [`acp-app`](../packages/bundle/acp-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-store`](../packages/client/store) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1593,7 +1720,7 @@ flowchart TD | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | @@ -1622,6 +1749,7 @@ flowchart TD | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1647,6 +1775,7 @@ flowchart TD | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1680,10 +1809,10 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1694,53 +1823,54 @@ flowchart TD | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | -| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`client-runtime`](../packages/client/runtime), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-slots`](../packages/client/ui-slots), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | -| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index a1207017f7..86298ce1ed 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -108,6 +108,8 @@ flowchart TD subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] + pkg_api_session_controller["api-session-controller"] + pkg_api_workspace_controller["api-workspace-controller"] end subgraph group_attachment["packages/attachment"] pkg_attachment["attachment"] @@ -129,10 +131,12 @@ flowchart TD pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] - pkg_client_runtime["client-runtime"] + pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_brand_official["client-ui-brand-official"] + pkg_client_ui_chat["client-ui-chat"] pkg_client_ui_commands["client-ui-commands"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -149,6 +153,7 @@ flowchart TD pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] + pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] @@ -362,7 +367,9 @@ flowchart TD pkg_acp_app --> pkg_invariants pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants + pkg_client_store --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants + pkg_client_ui_renderer --> pkg_invariants pkg_client_ui_slots --> pkg_invariants pkg_client_web --> pkg_invariants pkg_code_runtime --> pkg_invariants @@ -578,6 +585,7 @@ flowchart TD pkg_user_questions --> pkg_agent pkg_user_questions --> pkg_invariants pkg_user_questions --> pkg_llm + pkg_user_questions --> pkg_scope pkg_jobs --> pkg_agent pkg_jobs --> pkg_brand pkg_jobs --> pkg_invariants @@ -700,6 +708,8 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_session_telemetry + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -862,6 +872,10 @@ flowchart TD pkg_file_reference_local --> pkg_invariants pkg_file_reference_local --> pkg_system_prompt pkg_file_reference_local --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -1061,6 +1075,15 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_tool_todo + pkg_client_connection --> pkg_tools pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1081,9 +1104,6 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_cordis_host_runner - pkg_host_apiproxy --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1149,15 +1169,11 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_tool_todo - pkg_client_connection --> pkg_tools + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1202,10 +1218,6 @@ flowchart TD pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1225,12 +1237,39 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry - pkg_api_remotes --> pkg_agent + pkg_api_session_controller --> pkg_agent + pkg_api_session_controller --> pkg_agent_default_model + pkg_api_session_controller --> pkg_agent_presets + pkg_api_session_controller --> pkg_api_gateway + pkg_api_session_controller --> pkg_attachment + pkg_api_session_controller --> pkg_brand + pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_invariants + pkg_api_session_controller --> pkg_jobs + pkg_api_session_controller --> pkg_llm + pkg_api_session_controller --> pkg_scope + pkg_api_session_controller --> pkg_session + pkg_api_session_controller --> pkg_session_persistence + pkg_api_session_controller --> pkg_session_projection + pkg_api_session_controller --> pkg_session_projection_cache + pkg_api_session_controller --> pkg_session_query + pkg_api_session_controller --> pkg_session_title + pkg_api_session_controller --> pkg_subagent + pkg_api_session_controller --> pkg_tools + pkg_api_session_controller --> pkg_typert_protocol + pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_workspace + pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_invariants + pkg_api_workspace_controller --> pkg_session + pkg_api_workspace_controller --> pkg_storage_domain + pkg_api_workspace_controller --> pkg_typert_protocol + pkg_api_workspace_controller --> pkg_workspace pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway + pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_workspace_controller pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials @@ -1241,270 +1280,356 @@ flowchart TD pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session - pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings - pkg_api_remotes --> pkg_typert_registry - pkg_client_runtime --> pkg_agent - pkg_client_runtime --> pkg_api_remotes - pkg_client_runtime --> pkg_attachment - pkg_client_runtime --> pkg_client_connection - pkg_client_runtime --> pkg_commands - pkg_client_runtime --> pkg_host_apiproxy - pkg_client_runtime --> pkg_invariants - pkg_client_runtime --> pkg_llm - pkg_client_runtime --> pkg_llm_retry - pkg_client_runtime --> pkg_session - pkg_client_runtime --> pkg_session_projection - pkg_client_runtime --> pkg_session_title - pkg_client_runtime --> pkg_tool_todo - pkg_client_runtime --> pkg_tools - pkg_client_runtime --> pkg_typert_protocol - pkg_client_runtime --> pkg_typert_registry - pkg_client_ui_renderer --> pkg_client_runtime - pkg_client_ui_renderer --> pkg_invariants + pkg_api_remotes --> pkg_user_approval + pkg_api_remotes --> pkg_user_questions + pkg_client_ui_session --> pkg_api_session_controller + pkg_client_ui_session --> pkg_client_ui_renderer + pkg_client_ui_session --> pkg_invariants + pkg_client_ui_session --> pkg_session pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection - pkg_client_ui_settings --> pkg_client_runtime pkg_client_ui_settings --> pkg_invariants pkg_client_ui_settings --> pkg_settings pkg_client_locale --> pkg_api_remotes pkg_client_locale --> pkg_client_connection - pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_renderer pkg_client_locale --> pkg_client_ui_settings pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_renderer - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_input_trigger --> pkg_client_locale - pkg_client_ui_input_trigger --> pkg_client_runtime - pkg_client_ui_input_trigger --> pkg_file_reference - pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_connection pkg_client_ui_settings_models --> pkg_client_locale - pkg_client_ui_settings_models --> pkg_client_runtime + pkg_client_ui_settings_models --> pkg_client_ui_renderer pkg_client_ui_settings_models --> pkg_client_ui_settings pkg_client_ui_settings_models --> pkg_invariants pkg_client_ui_settings_plugin_inventory --> pkg_api_remotes pkg_client_ui_settings_plugin_inventory --> pkg_client_locale - pkg_client_ui_settings_plugin_inventory --> pkg_client_runtime + pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_renderer pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale - pkg_client_ui_settings_plugins --> pkg_client_runtime + pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings pkg_client_ui_settings_plugins --> pkg_invariants pkg_client_ui_theme --> pkg_api_remotes pkg_client_ui_theme --> pkg_client_connection pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_renderer pkg_client_ui_theme --> pkg_client_ui_settings pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_theme --> pkg_settings - pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_renderer + pkg_client_ui_layout --> pkg_client_ui_session pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_cordis_client_runner --> pkg_api_remotes + pkg_cordis_client_runner --> pkg_client_connection + pkg_cordis_client_runner --> pkg_client_modules + pkg_cordis_client_runner --> pkg_client_ui_renderer + pkg_cordis_client_runner --> pkg_client_ui_theme + pkg_cordis_client_runner --> pkg_invariants + pkg_client_ui_conversation --> pkg_api_remotes + pkg_client_ui_conversation --> pkg_api_session_controller + pkg_client_ui_conversation --> pkg_api_workspace_controller + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_ui_layout + pkg_client_ui_conversation --> pkg_client_ui_renderer + pkg_client_ui_conversation --> pkg_client_ui_session + pkg_client_ui_conversation --> pkg_client_ui_settings + pkg_client_ui_conversation --> pkg_client_ui_workspace + pkg_client_ui_conversation --> pkg_commands + pkg_client_ui_conversation --> pkg_goal + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm + pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_permission_presets + pkg_client_ui_conversation --> pkg_plan_mode + pkg_client_ui_conversation --> pkg_session + pkg_client_ui_conversation --> pkg_settings + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tool_todo + pkg_client_ui_conversation --> pkg_util_crypto + pkg_client_ui_conversation --> pkg_workspace + pkg_client_ui_sidebar --> pkg_api_workspace_controller + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_ui_layout + pkg_client_ui_sidebar --> pkg_client_ui_renderer + pkg_client_ui_sidebar --> pkg_client_ui_session + pkg_client_ui_sidebar --> pkg_client_ui_workspace + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_session_controller + pkg_client_ui_workspace --> pkg_api_workspace_controller + pkg_client_ui_workspace --> pkg_client_connection + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_ui_conversation + pkg_client_ui_workspace --> pkg_client_ui_renderer + pkg_client_ui_workspace --> pkg_client_ui_session + pkg_client_ui_workspace --> pkg_client_ui_sidebar + pkg_client_ui_workspace --> pkg_invariants + pkg_client_ui_workspace --> pkg_session + pkg_client_ui_agent_preset --> pkg_api_remotes + pkg_client_ui_agent_preset --> pkg_api_session_controller + pkg_client_ui_agent_preset --> pkg_client_connection + pkg_client_ui_agent_preset --> pkg_client_locale + pkg_client_ui_agent_preset --> pkg_client_ui_conversation + pkg_client_ui_agent_preset --> pkg_client_ui_renderer + pkg_client_ui_agent_preset --> pkg_client_ui_session + pkg_client_ui_agent_preset --> pkg_client_ui_settings + pkg_client_ui_agent_preset --> pkg_client_ui_workspace + pkg_client_ui_agent_preset --> pkg_invariants + pkg_client_ui_agent_preset --> pkg_session + pkg_client_ui_approval --> pkg_api_remotes + pkg_client_ui_approval --> pkg_api_session_controller + pkg_client_ui_approval --> pkg_client_locale + pkg_client_ui_approval --> pkg_client_ui_conversation + pkg_client_ui_approval --> pkg_client_ui_renderer + pkg_client_ui_approval --> pkg_client_ui_session + pkg_client_ui_approval --> pkg_invariants + pkg_client_ui_approval --> pkg_llm + pkg_client_ui_approval --> pkg_session + pkg_client_ui_approval --> pkg_typert_protocol + pkg_client_ui_brand_official --> pkg_client_ui_conversation + pkg_client_ui_brand_official --> pkg_client_ui_renderer + pkg_client_ui_brand_official --> pkg_client_ui_sidebar + pkg_client_ui_brand_official --> pkg_invariants + pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_client_locale + pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer + pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace + pkg_client_ui_directory_picker_browse --> pkg_invariants + pkg_client_ui_directory_picker_native --> pkg_client_ui_renderer + pkg_client_ui_directory_picker_native --> pkg_client_ui_workspace + pkg_client_ui_directory_picker_native --> pkg_invariants + pkg_client_ui_input_trigger --> pkg_api_session_controller + pkg_client_ui_input_trigger --> pkg_client_locale + pkg_client_ui_input_trigger --> pkg_client_ui_conversation + pkg_client_ui_input_trigger --> pkg_client_ui_renderer + pkg_client_ui_input_trigger --> pkg_client_ui_session + pkg_client_ui_input_trigger --> pkg_file_reference + pkg_client_ui_input_trigger --> pkg_invariants + pkg_client_ui_input_trigger --> pkg_session + pkg_client_ui_jobs --> pkg_api_session_controller + pkg_client_ui_jobs --> pkg_client_locale + pkg_client_ui_jobs --> pkg_client_ui_conversation + pkg_client_ui_jobs --> pkg_client_ui_renderer + pkg_client_ui_jobs --> pkg_client_ui_session + pkg_client_ui_jobs --> pkg_invariants + pkg_client_ui_plan --> pkg_api_remotes + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_renderer + pkg_client_ui_plan --> pkg_client_ui_session + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_plan --> pkg_session + pkg_client_ui_settings_general --> pkg_api_remotes + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_ui_renderer + pkg_client_ui_settings_general --> pkg_client_ui_session + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_sidebar + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_settings_general --> pkg_settings + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_client_locale + pkg_client_ui_trajectory --> pkg_client_ui_conversation + pkg_client_ui_trajectory --> pkg_client_ui_renderer + pkg_client_ui_trajectory --> pkg_client_ui_session + pkg_client_ui_trajectory --> pkg_compaction + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_session + pkg_client_ui_trajectory --> pkg_tools + pkg_client_ui_user_questions --> pkg_api_remotes + pkg_client_ui_user_questions --> pkg_api_session_controller + pkg_client_ui_user_questions --> pkg_client_locale + pkg_client_ui_user_questions --> pkg_client_ui_conversation + pkg_client_ui_user_questions --> pkg_client_ui_renderer + pkg_client_ui_user_questions --> pkg_client_ui_session + pkg_client_ui_user_questions --> pkg_invariants + pkg_client_ui_user_questions --> pkg_session + pkg_client_ui_user_questions --> pkg_typert_protocol + pkg_client_ui_user_questions --> pkg_user_questions + pkg_client_ui_chat --> pkg_agent + pkg_client_ui_chat --> pkg_api_remotes + pkg_client_ui_chat --> pkg_api_session_controller + pkg_client_ui_chat --> pkg_api_workspace_controller + pkg_client_ui_chat --> pkg_attachment + pkg_client_ui_chat --> pkg_client_locale + pkg_client_ui_chat --> pkg_client_ui_approval + pkg_client_ui_chat --> pkg_client_ui_conversation + pkg_client_ui_chat --> pkg_client_ui_layout + pkg_client_ui_chat --> pkg_client_ui_renderer + pkg_client_ui_chat --> pkg_client_ui_session + pkg_client_ui_chat --> pkg_client_ui_workspace + pkg_client_ui_chat --> pkg_commands + pkg_client_ui_chat --> pkg_compaction + pkg_client_ui_chat --> pkg_invariants + pkg_client_ui_chat --> pkg_llm + pkg_client_ui_chat --> pkg_llm_retry + pkg_client_ui_chat --> pkg_session + pkg_client_ui_chat --> pkg_session_stats + pkg_client_ui_chat --> pkg_token_meter + pkg_client_ui_chat --> pkg_tools + pkg_client_ui_commands --> pkg_api_remotes + pkg_client_ui_commands --> pkg_api_session_controller + pkg_client_ui_commands --> pkg_client_locale + pkg_client_ui_commands --> pkg_client_ui_conversation + pkg_client_ui_commands --> pkg_client_ui_input_trigger + pkg_client_ui_commands --> pkg_client_ui_renderer + pkg_client_ui_commands --> pkg_client_ui_session + pkg_client_ui_commands --> pkg_commands + pkg_client_ui_commands --> pkg_invariants + pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes pkg_client_ui_reference --> pkg_client_locale - pkg_client_ui_reference --> pkg_client_runtime pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol - pkg_cordis_client_runner --> pkg_api_remotes - pkg_cordis_client_runner --> pkg_client_connection - pkg_cordis_client_runner --> pkg_client_modules - pkg_cordis_client_runner --> pkg_client_runtime - pkg_cordis_client_runner --> pkg_client_ui_theme - pkg_cordis_client_runner --> pkg_invariants - pkg_client_ui_conversation --> pkg_agent - pkg_client_ui_conversation --> pkg_api_remotes - pkg_client_ui_conversation --> pkg_attachment - pkg_client_ui_conversation --> pkg_brand - pkg_client_ui_conversation --> pkg_client_connection - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_input_trigger - pkg_client_ui_conversation --> pkg_client_ui_layout - pkg_client_ui_conversation --> pkg_client_ui_settings - pkg_client_ui_conversation --> pkg_commands - pkg_client_ui_conversation --> pkg_compaction - pkg_client_ui_conversation --> pkg_goal - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_llm_retry - pkg_client_ui_conversation --> pkg_permission_presets - pkg_client_ui_conversation --> pkg_plan_mode - pkg_client_ui_conversation --> pkg_session_stats - pkg_client_ui_conversation --> pkg_settings - pkg_client_ui_conversation --> pkg_token_meter - pkg_client_ui_conversation --> pkg_tool_todo - pkg_client_ui_conversation --> pkg_tools - pkg_client_ui_conversation --> pkg_util_crypto - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_layout - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_agent_preset --> pkg_api_remotes - pkg_client_ui_agent_preset --> pkg_client_connection - pkg_client_ui_agent_preset --> pkg_client_locale - pkg_client_ui_agent_preset --> pkg_client_runtime - pkg_client_ui_agent_preset --> pkg_client_ui_conversation - pkg_client_ui_agent_preset --> pkg_client_ui_settings - pkg_client_ui_agent_preset --> pkg_invariants - pkg_client_ui_attachment --> pkg_attachment - pkg_client_ui_attachment --> pkg_client_runtime - pkg_client_ui_attachment --> pkg_client_ui_conversation - pkg_client_ui_attachment --> pkg_invariants - pkg_client_ui_brand_official --> pkg_client_runtime - pkg_client_ui_brand_official --> pkg_client_ui_conversation - pkg_client_ui_brand_official --> pkg_client_ui_sidebar - pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_commands --> pkg_api_remotes - pkg_client_ui_commands --> pkg_client_locale - pkg_client_ui_commands --> pkg_client_runtime - pkg_client_ui_commands --> pkg_client_ui_conversation - pkg_client_ui_commands --> pkg_client_ui_input_trigger - pkg_client_ui_commands --> pkg_commands - pkg_client_ui_commands --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_connection - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_deliverables --> pkg_system_prompt - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_commands - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_goal --> pkg_session - pkg_client_ui_goal --> pkg_typert_protocol - pkg_client_ui_jobs --> pkg_client_locale - pkg_client_ui_jobs --> pkg_client_runtime - pkg_client_ui_jobs --> pkg_client_ui_conversation - pkg_client_ui_jobs --> pkg_invariants - pkg_client_ui_message_feedback --> pkg_api_remotes - pkg_client_ui_message_feedback --> pkg_client_connection - pkg_client_ui_message_feedback --> pkg_client_locale - pkg_client_ui_message_feedback --> pkg_client_runtime - pkg_client_ui_message_feedback --> pkg_client_ui_conversation - pkg_client_ui_message_feedback --> pkg_invariants - pkg_client_ui_message_feedback --> pkg_message_feedback - pkg_client_ui_message_feedback --> pkg_typert_protocol - pkg_client_ui_plan --> pkg_api_remotes - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_settings_general --> pkg_api_remotes - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_sidebar - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_settings_general --> pkg_settings + pkg_client_ui_subagent --> pkg_api_session_controller + pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation pkg_client_ui_subagent --> pkg_client_ui_input_trigger + pkg_client_ui_subagent --> pkg_client_ui_renderer + pkg_client_ui_subagent --> pkg_client_ui_session pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_session pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter - pkg_client_ui_tool --> pkg_api_remotes - pkg_client_ui_tool --> pkg_client_connection - pkg_client_ui_tool --> pkg_client_locale - pkg_client_ui_tool --> pkg_client_runtime - pkg_client_ui_tool --> pkg_client_ui_conversation - pkg_client_ui_tool --> pkg_invariants - pkg_client_ui_trajectory --> pkg_agent - pkg_client_ui_trajectory --> pkg_client_locale - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_conversation - pkg_client_ui_trajectory --> pkg_compaction - pkg_client_ui_trajectory --> pkg_invariants - pkg_client_ui_trajectory --> pkg_tools - pkg_client_ui_user_questions --> pkg_api_remotes - pkg_client_ui_user_questions --> pkg_client_locale - pkg_client_ui_user_questions --> pkg_client_runtime - pkg_client_ui_user_questions --> pkg_client_ui_conversation - pkg_client_ui_user_questions --> pkg_invariants - pkg_client_ui_workflow_run --> pkg_client_locale - pkg_client_ui_workflow_run --> pkg_client_runtime - pkg_client_ui_workflow_run --> pkg_client_ui_conversation - pkg_client_ui_workflow_run --> pkg_invariants - pkg_client_ui_workflow_run --> pkg_session - pkg_client_ui_workflow_run --> pkg_tool_workflow - pkg_client_ui_workflow_run --> pkg_workflow - pkg_client_ui_workspace --> pkg_client_connection - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_conversation - pkg_client_ui_workspace --> pkg_client_ui_sidebar - pkg_client_ui_workspace --> pkg_invariants - pkg_session_log_export --> pkg_client_locale - pkg_session_log_export --> pkg_client_runtime - pkg_session_log_export --> pkg_client_ui_commands - pkg_session_log_export --> pkg_client_ui_conversation - pkg_session_log_export --> pkg_commands - pkg_session_log_export --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_locale - pkg_client_ui_directory_picker_browse --> pkg_client_runtime - pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace - pkg_client_ui_directory_picker_browse --> pkg_invariants - pkg_client_ui_directory_picker_native --> pkg_client_runtime - pkg_client_ui_directory_picker_native --> pkg_client_ui_workspace - pkg_client_ui_directory_picker_native --> pkg_invariants - pkg_client_ui_model_selection --> pkg_api_remotes - pkg_client_ui_model_selection --> pkg_client_connection - pkg_client_ui_model_selection --> pkg_client_locale - pkg_client_ui_model_selection --> pkg_client_runtime - pkg_client_ui_model_selection --> pkg_client_ui_commands - pkg_client_ui_model_selection --> pkg_client_ui_conversation - pkg_client_ui_model_selection --> pkg_client_ui_input_trigger - pkg_client_ui_model_selection --> pkg_invariants - pkg_client_ui_permission_presets --> pkg_api_remotes - pkg_client_ui_permission_presets --> pkg_client_connection - pkg_client_ui_permission_presets --> pkg_client_locale - pkg_client_ui_permission_presets --> pkg_client_runtime - pkg_client_ui_permission_presets --> pkg_client_ui_commands - pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger - pkg_client_ui_permission_presets --> pkg_client_ui_settings - pkg_client_ui_permission_presets --> pkg_invariants - pkg_client_ui_permission_presets --> pkg_permission_presets - pkg_client_ui_skill --> pkg_api_remotes - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_input_trigger - pkg_client_ui_skill --> pkg_client_ui_tool - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_cordis --> pkg_api_remotes - pkg_client_ui_cordis --> pkg_client_connection - pkg_client_ui_cordis --> pkg_client_locale - pkg_client_ui_cordis --> pkg_client_runtime - pkg_client_ui_cordis --> pkg_client_ui_input_trigger - pkg_client_ui_cordis --> pkg_client_ui_sidebar - pkg_client_ui_cordis --> pkg_client_ui_tool - pkg_client_ui_cordis --> pkg_cordis_client_runner - pkg_client_ui_cordis --> pkg_invariants pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_client_locale + pkg_session_log_export --> pkg_client_ui_commands + pkg_session_log_export --> pkg_client_ui_conversation + pkg_session_log_export --> pkg_client_ui_renderer + pkg_session_log_export --> pkg_client_ui_session + pkg_session_log_export --> pkg_commands + pkg_session_log_export --> pkg_invariants + pkg_client_ui_attachment --> pkg_attachment + pkg_client_ui_attachment --> pkg_client_ui_chat + pkg_client_ui_attachment --> pkg_client_ui_conversation + pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_connection + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_ui_chat + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_renderer + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_deliverables --> pkg_session + pkg_client_ui_deliverables --> pkg_system_prompt + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_api_session_controller + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_ui_chat + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_renderer + pkg_client_ui_goal --> pkg_client_ui_session + pkg_client_ui_goal --> pkg_commands + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_goal --> pkg_session + pkg_client_ui_goal --> pkg_typert_protocol + pkg_client_ui_message_feedback --> pkg_api_remotes + pkg_client_ui_message_feedback --> pkg_client_connection + pkg_client_ui_message_feedback --> pkg_client_locale + pkg_client_ui_message_feedback --> pkg_client_ui_chat + pkg_client_ui_message_feedback --> pkg_client_ui_conversation + pkg_client_ui_message_feedback --> pkg_client_ui_renderer + pkg_client_ui_message_feedback --> pkg_client_ui_session + pkg_client_ui_message_feedback --> pkg_invariants + pkg_client_ui_message_feedback --> pkg_message_feedback + pkg_client_ui_message_feedback --> pkg_session + pkg_client_ui_message_feedback --> pkg_typert_protocol + pkg_client_ui_model_selection --> pkg_api_remotes + pkg_client_ui_model_selection --> pkg_api_session_controller + pkg_client_ui_model_selection --> pkg_client_connection + pkg_client_ui_model_selection --> pkg_client_locale + pkg_client_ui_model_selection --> pkg_client_ui_commands + pkg_client_ui_model_selection --> pkg_client_ui_conversation + pkg_client_ui_model_selection --> pkg_client_ui_input_trigger + pkg_client_ui_model_selection --> pkg_client_ui_renderer + pkg_client_ui_model_selection --> pkg_client_ui_session + pkg_client_ui_model_selection --> pkg_invariants + pkg_client_ui_model_selection --> pkg_session + pkg_client_ui_model_selection --> pkg_typert_protocol + pkg_client_ui_permission_presets --> pkg_api_remotes + pkg_client_ui_permission_presets --> pkg_api_session_controller + pkg_client_ui_permission_presets --> pkg_client_connection + pkg_client_ui_permission_presets --> pkg_client_locale + pkg_client_ui_permission_presets --> pkg_client_ui_commands + pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger + pkg_client_ui_permission_presets --> pkg_client_ui_renderer + pkg_client_ui_permission_presets --> pkg_client_ui_session + pkg_client_ui_permission_presets --> pkg_client_ui_settings + pkg_client_ui_permission_presets --> pkg_invariants + pkg_client_ui_permission_presets --> pkg_permission_presets + pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_api_workspace_controller + pkg_client_ui_tool --> pkg_client_connection + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_ui_chat + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_renderer + pkg_client_ui_tool --> pkg_client_ui_session + pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_api_session_controller + pkg_client_ui_workflow_run --> pkg_client_locale + pkg_client_ui_workflow_run --> pkg_client_ui_chat + pkg_client_ui_workflow_run --> pkg_client_ui_conversation + pkg_client_ui_workflow_run --> pkg_client_ui_renderer + pkg_client_ui_workflow_run --> pkg_client_ui_session + pkg_client_ui_workflow_run --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_session + pkg_client_ui_workflow_run --> pkg_tool_workflow + pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_test_runtime --> pkg_api_session_controller + pkg_client_test_runtime --> pkg_api_workspace_controller + pkg_client_test_runtime --> pkg_attachment + pkg_client_test_runtime --> pkg_client_connection + pkg_client_test_runtime --> pkg_client_store + pkg_client_test_runtime --> pkg_client_ui_chat + pkg_client_test_runtime --> pkg_client_ui_conversation + pkg_client_test_runtime --> pkg_client_ui_renderer + pkg_client_test_runtime --> pkg_client_ui_session + pkg_client_test_runtime --> pkg_client_ui_settings + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_invariants + pkg_client_test_runtime --> pkg_session + pkg_client_ui_skill --> pkg_api_remotes + pkg_client_ui_skill --> pkg_api_session_controller + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_ui_input_trigger + pkg_client_ui_skill --> pkg_client_ui_renderer + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants + pkg_client_ui_skill --> pkg_session + pkg_client_ui_cordis --> pkg_api_remotes + pkg_client_ui_cordis --> pkg_client_connection + pkg_client_ui_cordis --> pkg_client_locale + pkg_client_ui_cordis --> pkg_client_ui_input_trigger + pkg_client_ui_cordis --> pkg_client_ui_renderer + pkg_client_ui_cordis --> pkg_client_ui_session + pkg_client_ui_cordis --> pkg_client_ui_sidebar + pkg_client_ui_cordis --> pkg_client_ui_tool + pkg_client_ui_cordis --> pkg_cordis_client_runner + pkg_client_ui_cordis --> pkg_invariants ``` | Package | Group | Depends on | @@ -1524,7 +1649,9 @@ flowchart TD | [`acp-app`](../packages/bundle/acp-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-store`](../packages/client/store) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1595,7 +1722,7 @@ flowchart TD | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | @@ -1624,6 +1751,7 @@ flowchart TD | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1649,6 +1777,7 @@ flowchart TD | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1682,10 +1811,10 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1696,53 +1825,54 @@ flowchart TD | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | -| [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`client-runtime`](../packages/client/runtime), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-slots`](../packages/client/ui-slots), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | -| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`workspace`](../packages/workspace/workspace) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ea2e211bea..06c60504dc 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 8b1206ba18a6f49a0eb809a1a3a12828fd94f827 -persistence-catalog.zh.md: 9a085ece1fd2df331f34e311c48bfe0c8ca92c91 +persistence-catalog.md: 5155968af0886a389d0b01d9332af927cff95a55 +persistence-catalog.zh.md: abd4ae767a5cfca45be76b54d392815244070869 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8b1206ba18..5155968af0 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -115,7 +115,7 @@ Sources: [`packages/core/session/src/types.ts:321`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:38`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -160,7 +160,7 @@ Source: [`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/a Types: [CallId](subsystems/core.md) -Source: [`packages/interaction/user-approval/src/index.ts:44`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/types.ts:44`](../packages/interaction/user-approval/src/types.ts) @@ -178,7 +178,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:44`](../packages/inter } ``` -Source: [`packages/interaction/user-approval/src/index.ts:55`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/types.ts:55`](../packages/interaction/user-approval/src/types.ts) @@ -200,7 +200,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:55`](../packages/inter } ``` -Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:32`](../packages/interaction/user-approval/src/index.ts) ### `assistant/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 9a085ece1f..abd4ae767a 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -117,7 +117,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:38`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -162,7 +162,7 @@ export type SessionEvent = { 类型:[CallId](subsystems/core.zh.md) -来源:[`packages/interaction/user-approval/src/index.ts:44`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/types.ts:44`](../packages/interaction/user-approval/src/types.ts) @@ -180,7 +180,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/user-approval/src/index.ts:55`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/types.ts:55`](../packages/interaction/user-approval/src/types.ts) @@ -202,7 +202,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/user-approval/src/index.ts:67`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:32`](../packages/interaction/user-approval/src/index.ts) ### `assistant/*` diff --git a/docs/subsystems/approval.i18n.yaml b/docs/subsystems/approval.i18n.yaml index 0fa3e7df83..a52cf9a865 100644 --- a/docs/subsystems/approval.i18n.yaml +++ b/docs/subsystems/approval.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/approval.md -approval.md: 7d3d09314f8fbb151cc8a00dbbee5e2cc14e2c9a -approval.zh.md: 22d0b0ec4242fcb2ad6fb82c29893a5914369238 +approval.md: 7b12e7f766555fda09b5b2ac405129b8bfe17daf +approval.zh.md: 7596f28d51ef6dfd4e883eaff8c155111e1d2f1c diff --git a/docs/subsystems/approval.md b/docs/subsystems/approval.md index 7d3d09314f..7b12e7f766 100644 --- a/docs/subsystems/approval.md +++ b/docs/subsystems/approval.md @@ -57,7 +57,7 @@ Both policies contribute their complete current meaning to the cache-safe runtim * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ -interface ApprovalRequest { +interface ApprovalRequest extends ApprovalRequestEvent { /** * The agent on whose behalf the question is asked. Routes the question (a * UI answerer only answers for agents it owns) and receives the audit @@ -151,20 +151,20 @@ Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/inter #### `approval/request` — waterfall -Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Ask composed answerers for one decision. Return an outcome to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog /** * Ask composed answerers for one decision. Return an outcome to claim the - * request or call `next()`; failure yields the fail-closed default. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param req - the pending decision (agent, tool identity, reason, signal). + * request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - pending approval request. * @mode waterfall */ -'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise +'approval/request'( this: Scoped, req: ApprovalRequestEvent, next: () => Promise, ): Promise ``` -Types: [Scoped](scope.md) +Types: [Agent](core.md) · [Scoped](scope.md) -Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/types.ts`](../../packages/interaction/user-approval/src/types.ts) diff --git a/docs/subsystems/approval.zh.md b/docs/subsystems/approval.zh.md index 22d0b0ec42..7596f28d51 100644 --- a/docs/subsystems/approval.zh.md +++ b/docs/subsystems/approval.zh.md @@ -57,7 +57,7 @@ type ApprovalPolicy = 'ask' | 'never' * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ -interface ApprovalRequest { +interface ApprovalRequest extends ApprovalRequestEvent { /** * The agent on whose behalf the question is asked. Routes the question (a * UI answerer only answers for agents it owns) and receives the audit @@ -151,20 +151,20 @@ Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/inter #### `approval/request` — waterfall -Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Ask composed answerers for one decision. Return an outcome to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog /** * Ask composed answerers for one decision. Return an outcome to claim the - * request or call `next()`; failure yields the fail-closed default. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param req - the pending decision (agent, tool identity, reason, signal). + * request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - pending approval request. * @mode waterfall */ -'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise +'approval/request'( this: Scoped, req: ApprovalRequestEvent, next: () => Promise, ): Promise ``` -Types: [Scoped](scope.zh.md) +Types: [Agent](core.zh.md) · [Scoped](scope.zh.md) -Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/types.ts`](../../packages/interaction/user-approval/src/types.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 3ff9398577..9174e27772 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 73c724756e6a8dfdf8723871db646d50fab089d4 -core.zh.md: a3c2cbaed4a019afaf4aab442191a60b392303c8 +core.md: 3417560539f41009a290f4c31b255f338624d56d +core.zh.md: 75f3c0ca16e1235cb2155f6e54e86e414e61b498 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 73c724756e..3417560539 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -57,9 +57,9 @@ interface AgentHandle { Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv -/** Public live-agent handle. */ +/** Public live-agent handle; the runtime face augments its live capabilities. */ interface Agent { - /** The single identity shared with {@link session}. */ + /** Session-backed Agent identity. */ readonly id: SessionId /** The provider route and model this agent's requests use. */ readonly options: AgentOptions diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index a3c2cbaed4..75f3c0ca16 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -61,9 +61,9 @@ interface AgentHandle { 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv -/** Public live-agent handle. */ +/** Public live-agent handle; the runtime face augments its live capabilities. */ interface Agent { - /** The single identity shared with {@link session}. */ + /** Session-backed Agent identity. */ readonly id: SessionId /** The provider route and model this agent's requests use. */ readonly options: AgentOptions diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 03991ac2d1..61ab54ba12 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: ccd4b0305c6253f7a00930ca0f0f2e5ffd5195b9 -session-projection.zh.md: 6b62f8898dfae7dc1d382e2c5466c5e8c3e725e9 +session-projection.md: 8614cf3466eff8deb360a7667ff6b4e37da1bf6e +session-projection.zh.md: b9e213b60e0df9de54e4c4805d11e64ca866dad2 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index ccd4b0305c..8614cf3466 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -70,7 +70,7 @@ The whole-value event rule is load-bearing: a state-carrying log event carries t /** * One consistent read cut over every registered client-visible unit for one session. * `asOfSeq` is the shared watermark — the seq of the last event every value - * reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`). + * reflects (`-1` for an empty log). */ interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 6b62f8898d..b9e213b60e 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -70,7 +70,7 @@ interface ProjectionDefinition< /** * One consistent read cut over every registered client-visible unit for one session. * `asOfSeq` is the shared watermark — the seq of the last event every value - * reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`). + * reflects (`-1` for an empty log). */ interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 6f00550398..b101b92344 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: e9a8d81cbec84b427972d33ffa7902031d310a52 -session.zh.md: ee77131546f3b35bd7c0033348aec0207eb99e03 +session.md: 7d80adfc25e3ebb9f482a3e1c84c16163dba318e +session.zh.md: 6337a54bd221a3908793c2231bcde4af8a879bb9 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index e9a8d81cbe..7d80adfc25 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -591,6 +591,136 @@ The backends that consume this contract are on [persistence.md](persistence.md). Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.sessionController` — `SessionController` + +Host service backing the generated `ctx.remote.session` namespace. + +```ts cordis-catalog +/** + * Resolve or resume one ordinary Session for another Host API domain. + * @param sessionId - Session identity whose Agent owns the operation. + * @returns the live Agent or the stable Session-domain failure. + */ +resolveAgent(sessionId: SessionId): Promise + +/** + * Inspect one attached or persisted Session without activating its Agent. + * @param sessionId - durable Session identity. + * @param signal - optional caller cancellation for persistence reads. + * @returns the current attached state or persisted header and event prefix. + */ +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + +/** + * Read all visible Session rows without resuming an Agent. + * @param _request - reserved empty list request. + * @param signal - cancellation for persistence reads. + * @returns visible Session summaries ordered by activity. + */ +@Remote('list') async list(_request: SessionListRequest, signal: AbortSignal): Promise + +/** + * Search visible Session content without resuming an Agent. + * @param request - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ +@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise + +/** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ +@Remote('create') create(request: SessionCreateRequest): Promise + +/** + * Read model choices after explicitly resuming the addressed Session. + * @param request - Session whose model state is requested. + * @returns the current selection and available model groups. + */ +@Remote('models') models(request: SessionModelsRequest): Promise + +/** + * Select one Session-local model after explicitly resuming the Session. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session. + */ +@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise + +/** + * Rename one Session after explicitly resuming it. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ +@Remote('rename') rename(request: SessionRenameRequest): Promise + +/** + * Fork one cold-readable completed-turn prefix into a new Session. + * @param request - source Session and optional event anchor. + * @returns the new Session identity. + */ +@Remote('fork') fork(request: SessionForkRequest): Promise + +/** + * Admit one prompt after explicitly resuming its Session. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @param signal - caller cancellation before prompt admission begins. + * @returns acknowledgement that the Agent accepted the prompt. + */ +@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise + +/** + * Read one image proven reachable from the addressed Session log. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ +@Remote('attachment') attachment(request: SessionAttachmentRequest): Promise + +/** + * Mutate one still-pending queue occurrence on a live Agent. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ +@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue + +/** + * Cancel one active Agent turn without dropping its pending inbox. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ +@Remote('cancel') cancel(request: SessionCancelRequest): SessionCancelValue + +/** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, backward cursor, and page budget. + * @param signal - cancellation for persistence and presentation reads. + * @returns one chronological page and optional latest projections. + */ +@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Follow one Session log from its opening or resume cursor. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns an opened cursor followed by gap-free event frames. + */ +@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream a complete live-control baseline followed by replacement frames. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns one complete baseline followed by live replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable +``` + +Types: [SessionHeader](persistence.md) · [SessionId](core.md) · [SessionSearchRequest](session-query.md) + +Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) + ### `ctx.sessions` — `SessionStore` @@ -727,6 +857,106 @@ Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persist Source: [`packages/core/session/src/index.ts`](../../packages/core/session/src/index.ts) + + +### `api-session/*` events + + + +#### `api-session/activity` — emit + +One user-authored durable message advanced Session list activity. + +```ts cordis-catalog +/** + * One user-authored durable message advanced Session list activity. + * @mode emit + * @param sessionId - addressed Session identity. + * @param updatedAt - durable message time used for list ordering. + */ +'api-session/activity'(sessionId: SessionId, updatedAt: number): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/added` — emit + +A Session became visible to Session list consumers. + +```ts cordis-catalog +/** + * A Session became visible to Session list consumers. + * @mode emit + * @param summary - initial list row for the Session. + */ +'api-session/added'(summary: SessionSummary): void +``` + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/error` — emit + +One Agent failed outside a durable turn position. + +```ts cordis-catalog +/** + * One Agent failed outside a durable turn position. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param message - user-safe failure chain. + */ +'api-session/error'(sessionId: SessionId, message: string): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/removed` — emit + +A Session left the live Host registry. + +```ts cordis-catalog +/** + * A Session left the live Host registry. + * @mode emit + * @param sessionId - removed Session identity. + */ +'api-session/removed'(sessionId: SessionId): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/status` — emit + +One Agent changed running state. + +```ts cordis-catalog +/** + * One Agent changed running state. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param running - whether the Agent is running. + */ +'api-session/status'(sessionId: SessionId, running: boolean): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + ### `session/*` events diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index ee77131546..6337a54bd2 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -595,6 +595,136 @@ interface TurnEndReasonMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.sessionController` — `SessionController` + +Host service backing the generated `ctx.remote.session` namespace. + +```ts cordis-catalog +/** + * Resolve or resume one ordinary Session for another Host API domain. + * @param sessionId - Session identity whose Agent owns the operation. + * @returns the live Agent or the stable Session-domain failure. + */ +resolveAgent(sessionId: SessionId): Promise + +/** + * Inspect one attached or persisted Session without activating its Agent. + * @param sessionId - durable Session identity. + * @param signal - optional caller cancellation for persistence reads. + * @returns the current attached state or persisted header and event prefix. + */ +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + +/** + * Read all visible Session rows without resuming an Agent. + * @param _request - reserved empty list request. + * @param signal - cancellation for persistence reads. + * @returns visible Session summaries ordered by activity. + */ +@Remote('list') async list(_request: SessionListRequest, signal: AbortSignal): Promise + +/** + * Search visible Session content without resuming an Agent. + * @param request - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ +@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise + +/** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ +@Remote('create') create(request: SessionCreateRequest): Promise + +/** + * Read model choices after explicitly resuming the addressed Session. + * @param request - Session whose model state is requested. + * @returns the current selection and available model groups. + */ +@Remote('models') models(request: SessionModelsRequest): Promise + +/** + * Select one Session-local model after explicitly resuming the Session. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session. + */ +@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise + +/** + * Rename one Session after explicitly resuming it. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ +@Remote('rename') rename(request: SessionRenameRequest): Promise + +/** + * Fork one cold-readable completed-turn prefix into a new Session. + * @param request - source Session and optional event anchor. + * @returns the new Session identity. + */ +@Remote('fork') fork(request: SessionForkRequest): Promise + +/** + * Admit one prompt after explicitly resuming its Session. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @param signal - caller cancellation before prompt admission begins. + * @returns acknowledgement that the Agent accepted the prompt. + */ +@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise + +/** + * Read one image proven reachable from the addressed Session log. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ +@Remote('attachment') attachment(request: SessionAttachmentRequest): Promise + +/** + * Mutate one still-pending queue occurrence on a live Agent. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ +@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue + +/** + * Cancel one active Agent turn without dropping its pending inbox. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ +@Remote('cancel') cancel(request: SessionCancelRequest): SessionCancelValue + +/** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, backward cursor, and page budget. + * @param signal - cancellation for persistence and presentation reads. + * @returns one chronological page and optional latest projections. + */ +@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Follow one Session log from its opening or resume cursor. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns an opened cursor followed by gap-free event frames. + */ +@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream a complete live-control baseline followed by replacement frames. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns one complete baseline followed by live replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable +``` + +Types: [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md) + +Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts) + ### `ctx.sessions` — `SessionStore` @@ -731,6 +861,106 @@ Types: [CreateSessionOptions](persistence.zh.md) · [PrepareSessionOptions](pers Source: [`packages/core/session/src/index.ts`](../../packages/core/session/src/index.ts) + + +### `api-session/*` events + + + +#### `api-session/activity` — emit + +One user-authored durable message advanced Session list activity. + +```ts cordis-catalog +/** + * One user-authored durable message advanced Session list activity. + * @mode emit + * @param sessionId - addressed Session identity. + * @param updatedAt - durable message time used for list ordering. + */ +'api-session/activity'(sessionId: SessionId, updatedAt: number): void +``` + +Types: [SessionId](core.zh.md) + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/added` — emit + +A Session became visible to Session list consumers. + +```ts cordis-catalog +/** + * A Session became visible to Session list consumers. + * @mode emit + * @param summary - initial list row for the Session. + */ +'api-session/added'(summary: SessionSummary): void +``` + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/error` — emit + +One Agent failed outside a durable turn position. + +```ts cordis-catalog +/** + * One Agent failed outside a durable turn position. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param message - user-safe failure chain. + */ +'api-session/error'(sessionId: SessionId, message: string): void +``` + +Types: [SessionId](core.zh.md) + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/removed` — emit + +A Session left the live Host registry. + +```ts cordis-catalog +/** + * A Session left the live Host registry. + * @mode emit + * @param sessionId - removed Session identity. + */ +'api-session/removed'(sessionId: SessionId): void +``` + +Types: [SessionId](core.zh.md) + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/status` — emit + +One Agent changed running state. + +```ts cordis-catalog +/** + * One Agent changed running state. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param running - whether the Agent is running. + */ +'api-session/status'(sessionId: SessionId, running: boolean): void +``` + +Types: [SessionId](core.zh.md) + +Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/session-controller/src/types.ts) + ### `session/*` events diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index f3135fdb93..598956872e 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/typert.md -typert.md: 46d9e7c7ef5e5366b165f4dc9217ca9fc02712ab -typert.zh.md: ff92d94f31c9fe1d2e5469cb237751c3f742598e +typert.md: bf43280f7fa01e6300caeaadeadb2c6c8f78cdd2 +typert.zh.md: c50663ad2546d864efc5648059dde735ae4de5bd diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 46d9e7c7ef..bf43280f7f 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -84,6 +84,8 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string + /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -95,7 +97,7 @@ interface InvocationDescriptor { } /** Optional consuming-Context projection for one direct lookup parameter. */ readonly scope?: { - /** Context kind whose Client binder supplies the identity. */ + /** Context kind whose Client adapter supplies the identity. */ readonly context: string /** Lookup parameter wire field replaced by the Context identity. */ readonly wire: string @@ -107,7 +109,7 @@ interface InvocationDescriptor { /** Reserved final Host method parameter. */ readonly parameter: 'signal' } - /** Codec for the resolved method result. */ + /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation @@ -178,13 +180,27 @@ type TypertGatewayErrorCode = ```ts type-equiv /** Host dispatcher consumed by Connection adapters. */ interface TypertGateway { + /** Carrier adapter shared by WebSocket and in-process transports. */ + readonly wireStream: TypertGatewayWireStream + /** + * Register the application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this exact source and cancelling its active streams. + */ + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. - * @returns the validated business result. + * @returns the business result without output decoding. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns a cancellation-aware iterable over the business results. + */ + stream(request: InvokeRemoteRequest): Promise> } ``` @@ -202,26 +218,15 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { */ $mount(contribution: TypertRemoteContribution): Promise /** - * Subscribe to one forwarded Host event; delivery is one-way, in registration - * order, and isolates a throwing listener from the rest. + * Subscribe to one forwarded Host event. Notifications run in registration + * order and isolate failures; scoped waterfalls return, delegate through + * `next()`, or reject the Host dispatch. * @template Event - forwarded event name selected by the Host assembly. * @param event - forwarded Host event name, unchanged on the wire. - * @param listener - receives the Host's argument list as declared by Cordis `Events`. + * @param listener - receives the Client projection of the Cordis `Events` declaration. * @returns disposer owned by the calling fiber. */ - $on(event: Event, listener: Events[Event]): () => void - /** - * Hand one decoded forwarded frame to the subscription table. The carrier - * owning the Host frame sink calls this; a consumer subscribes with - * {@link TypertClientRemote.$on} and never calls it. - * - * `event` is a plain string because this is the wire boundary: the name is - * whatever the Host assembly's allowlist selected, and one nobody subscribed - * to is dropped silently. - * @param event - forwarded Host event name, exactly as the Host emitted it. - * @param args - the Host argument list, already JSON-decoded. - */ - $dispatch(event: string, args: readonly unknown[]): void + $on(event: Event, listener: TypertClientEventListener): () => void } ``` @@ -239,15 +244,6 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. -```ts cordis-catalog -/** - * Response entry for server requests; not a domain method. - * @param message - Client response carrying the server request's rpcId. - * @returns Transport receipt for the response delivery. - */ -respond(message: ClientResponse): Promise -``` - Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) @@ -323,13 +319,27 @@ Source: [`packages/typert/registry/src/service.ts`](../../packages/typert/regist Resolve strict generated definitions or conservative SRC markers against current Cordis Services and Typert providers. ```ts cordis-catalog +/** + * Register the sole application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this source and cancelling its active streams. + */ +registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + /** * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. - * @returns the validated business result. + * @returns the business result without output decoding. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise + +/** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns a cancellation-aware iterable over the business results. + */ +async stream(request: InvokeRemoteRequest): Promise> ``` Source: [`packages/api/gateway/src/index.ts`](../../packages/api/gateway/src/index.ts) diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index ff92d94f31..c50663ad25 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -84,6 +84,8 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string + /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -95,7 +97,7 @@ interface InvocationDescriptor { } /** Optional consuming-Context projection for one direct lookup parameter. */ readonly scope?: { - /** Context kind whose Client binder supplies the identity. */ + /** Context kind whose Client adapter supplies the identity. */ readonly context: string /** Lookup parameter wire field replaced by the Context identity. */ readonly wire: string @@ -107,7 +109,7 @@ interface InvocationDescriptor { /** Reserved final Host method parameter. */ readonly parameter: 'signal' } - /** Codec for the resolved method result. */ + /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation @@ -178,13 +180,27 @@ type TypertGatewayErrorCode = ```ts type-equiv /** Host dispatcher consumed by Connection adapters. */ interface TypertGateway { + /** Carrier adapter shared by WebSocket and in-process transports. */ + readonly wireStream: TypertGatewayWireStream + /** + * Register the application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this exact source and cancelling its active streams. + */ + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. - * @returns the validated business result. + * @returns the business result without output decoding. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns a cancellation-aware iterable over the business results. + */ + stream(request: InvokeRemoteRequest): Promise> } ``` @@ -202,26 +218,15 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { */ $mount(contribution: TypertRemoteContribution): Promise /** - * Subscribe to one forwarded Host event; delivery is one-way, in registration - * order, and isolates a throwing listener from the rest. + * Subscribe to one forwarded Host event. Notifications run in registration + * order and isolate failures; scoped waterfalls return, delegate through + * `next()`, or reject the Host dispatch. * @template Event - forwarded event name selected by the Host assembly. * @param event - forwarded Host event name, unchanged on the wire. - * @param listener - receives the Host's argument list as declared by Cordis `Events`. + * @param listener - receives the Client projection of the Cordis `Events` declaration. * @returns disposer owned by the calling fiber. */ - $on(event: Event, listener: Events[Event]): () => void - /** - * Hand one decoded forwarded frame to the subscription table. The carrier - * owning the Host frame sink calls this; a consumer subscribes with - * {@link TypertClientRemote.$on} and never calls it. - * - * `event` is a plain string because this is the wire boundary: the name is - * whatever the Host assembly's allowlist selected, and one nobody subscribed - * to is dropped silently. - * @param event - forwarded Host event name, exactly as the Host emitted it. - * @param args - the Host argument list, already JSON-decoded. - */ - $dispatch(event: string, args: readonly unknown[]): void + $on(event: Event, listener: TypertClientEventListener): () => void } ``` @@ -239,15 +244,6 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. -```ts cordis-catalog -/** - * Response entry for server requests; not a domain method. - * @param message - Client response carrying the server request's rpcId. - * @returns Transport receipt for the response delivery. - */ -respond(message: ClientResponse): Promise -``` - Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) @@ -323,13 +319,27 @@ Source: [`packages/typert/registry/src/service.ts`](../../packages/typert/regist Resolve strict generated definitions or conservative SRC markers against current Cordis Services and Typert providers. ```ts cordis-catalog +/** + * Register the sole application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this source and cancelling its active streams. + */ +registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + /** * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. - * @returns the validated business result. + * @returns the business result without output decoding. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise + +/** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns a cancellation-aware iterable over the business results. + */ +async stream(request: InvokeRemoteRequest): Promise> ``` Source: [`packages/api/gateway/src/index.ts`](../../packages/api/gateway/src/index.ts) diff --git a/docs/subsystems/user-questions.i18n.yaml b/docs/subsystems/user-questions.i18n.yaml index 56142c7aef..1138e03de1 100644 --- a/docs/subsystems/user-questions.i18n.yaml +++ b/docs/subsystems/user-questions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/user-questions.md -user-questions.md: f6a8fe611caf9f51c9a233a96be25d69a839e3f3 -user-questions.zh.md: c7fc7f911261b8a7c3c3490a2ca059a2ecdca120 +user-questions.md: fbbfb1435586c7191e47a6eaa5b1783c9f172d48 +user-questions.zh.md: 054ca06cd3c8a85cb35d99658c325848ea780cb5 diff --git a/docs/subsystems/user-questions.md b/docs/subsystems/user-questions.md index f6a8fe611c..fbbfb14355 100644 --- a/docs/subsystems/user-questions.md +++ b/docs/subsystems/user-questions.md @@ -2,7 +2,7 @@ English | [中文](user-questions.zh.md) -The user-questions seam of [dsh-user-questions](../../packages/interaction/user-questions). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserQuestionProvider`; the host runtime relays requests to its connected client. +The user-questions seam of [dsh-user-questions](../../packages/interaction/user-questions). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. Agent-scoped waterfall listeners compose the available UI surfaces, including listeners relayed to a connected client. Source: [`packages/interaction/user-questions/src/index.ts`](../../packages/interaction/user-questions/src/index.ts) @@ -74,14 +74,7 @@ interface AskUserQuestionItem { ```ts type-equiv /** Request for a human answer. */ -interface AskUserQuestionRequest { - /** Questions to display. */ - questions: AskUserQuestionItem[] - /** Exact live calling agent, when the request came from an agent tool call. */ - agent?: Agent - /** Abort signal for the owning tool/step. */ - signal?: AbortSignal -} +interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {} ``` ## Answer @@ -108,17 +101,6 @@ interface AskUserQuestionAnswer { } ``` -## Provider - -Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI. - -```ts type-equiv -/** UI-side provider for user questions. */ -interface UserQuestionProvider { - ask(request: AskUserQuestionRequest): Promise -} -``` - ## Errors `UserQuestionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or UI-side cancellation. @@ -145,19 +127,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.userQuestions` — `UserQuestionService` -`ctx.userQuestions`: one active UI provider plus an `ask()` API. +`ctx.userQuestions`: validation plus the scoped answerer waterfall. ```ts cordis-catalog /** - * Register the UI provider. Only one provider may be active in a context. - * - * @param provider UI-side implementation that collects answers. - * @returns Disposer that unregisters this provider. - */ -registerProvider(provider: UserQuestionProvider): () => void - -/** - * Ask the active UI provider and wait for the user's answer. + * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the * exact live runtime root. Runtime ownership, not durable session lineage, @@ -167,12 +141,38 @@ registerProvider(provider: UserQuestionProvider): () => void * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. - * @throws {UserQuestionError} code `CALLER_NOT_LIVE` when a supplied - * agent is not the registry's exact live instance, or `DELEGATED_CALLER` - * when that live agent is owned by another agent. + * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal + * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent + * is not the registry's exact live instance, or `DELEGATED_CALLER` when + * that live agent is owned by another agent. */ async ask(request: AskUserQuestionRequest): Promise ``` Source: [`packages/interaction/user-questions/src/index.ts`](../../packages/interaction/user-questions/src/index.ts) + + + +### `user-questions/*` events + + + +#### `user-questions/request` — waterfall + +Ask composed answerers for structured user input. Return an answer to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + +```ts cordis-catalog +/** + * Ask composed answerers for structured user input. Return an answer to + * claim the request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param request - pending user-question request. + * @mode waterfall + */ +'user-questions/request'( this: Scoped, request: AskUserQuestionRequestEvent, next: () => Promise, ): Promise +``` + +Types: [Agent](core.md) · [Scoped](scope.md) + +Source: [`packages/interaction/user-questions/src/types.ts`](../../packages/interaction/user-questions/src/types.ts) diff --git a/docs/subsystems/user-questions.zh.md b/docs/subsystems/user-questions.zh.md index c7fc7f9112..054ca06cd3 100644 --- a/docs/subsystems/user-questions.zh.md +++ b/docs/subsystems/user-questions.zh.md @@ -2,7 +2,7 @@ [English](user-questions.md) | 中文 -[dsh-user-questions](../../packages/interaction/user-questions) 的用户交互 seam。它是工具或权限插件需要人类回答后 agent(智能体)才能继续时所使用的、提供方无关的词汇。UI 界面提供活跃的 `UserQuestionProvider`;host 运行时把请求转发给其连接的客户端。 +[dsh-user-questions](../../packages/interaction/user-questions) 的用户交互 seam。它是工具或权限插件需要人类回答后 agent(智能体)才能继续时所使用的、提供方无关的词汇。Agent-scoped waterfall listener 组合可用的 UI 界面,其中包括转发到已连接 client 的 listener。 源码:[`packages/interaction/user-questions/src/index.ts`](../../packages/interaction/user-questions/src/index.ts) @@ -74,14 +74,7 @@ interface AskUserQuestionItem { ```ts type-equiv /** Request for a human answer. */ -interface AskUserQuestionRequest { - /** Questions to display. */ - questions: AskUserQuestionItem[] - /** Exact live calling agent, when the request came from an agent tool call. */ - agent?: Agent - /** Abort signal for the owning tool/step. */ - signal?: AbortSignal -} +interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {} ``` ## 回答 @@ -108,17 +101,6 @@ interface AskUserQuestionAnswer { } ``` -## 提供方 - -同一上下文中只能有一个活跃的提供方。提供方注册绑定到 effect,因此 HMR(热模块替换)或 dispose(资源释放)会移除当前活跃的 UI。 - -```ts type-equiv -/** UI-side provider for user questions. */ -interface UserQuestionProvider { - ask(request: AskUserQuestionRequest): Promise -} -``` - ## 错误 `UserQuestionError` 继承 `HarnessError`,因此 `ctx.tools.execute()` 会保留 `{ name, code }`,用于面向模型的工具失败,如 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`ASK_ABORTED` 或 UI 侧取消。 @@ -145,19 +127,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.userQuestions` — `UserQuestionService` -`ctx.userQuestions`: one active UI provider plus an `ask()` API. +`ctx.userQuestions`: validation plus the scoped answerer waterfall. ```ts cordis-catalog /** - * Register the UI provider. Only one provider may be active in a context. - * - * @param provider UI-side implementation that collects answers. - * @returns Disposer that unregisters this provider. - */ -registerProvider(provider: UserQuestionProvider): () => void - -/** - * Ask the active UI provider and wait for the user's answer. + * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the * exact live runtime root. Runtime ownership, not durable session lineage, @@ -167,12 +141,38 @@ registerProvider(provider: UserQuestionProvider): () => void * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. - * @throws {UserQuestionError} code `CALLER_NOT_LIVE` when a supplied - * agent is not the registry's exact live instance, or `DELEGATED_CALLER` - * when that live agent is owned by another agent. + * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal + * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent + * is not the registry's exact live instance, or `DELEGATED_CALLER` when + * that live agent is owned by another agent. */ async ask(request: AskUserQuestionRequest): Promise ``` Source: [`packages/interaction/user-questions/src/index.ts`](../../packages/interaction/user-questions/src/index.ts) + + + +### `user-questions/*` events + + + +#### `user-questions/request` — waterfall + +Ask composed answerers for structured user input. Return an answer to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + +```ts cordis-catalog +/** + * Ask composed answerers for structured user input. Return an answer to + * claim the request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param request - pending user-question request. + * @mode waterfall + */ +'user-questions/request'( this: Scoped, request: AskUserQuestionRequestEvent, next: () => Promise, ): Promise +``` + +Types: [Agent](core.zh.md) · [Scoped](scope.zh.md) + +Source: [`packages/interaction/user-questions/src/types.ts`](../../packages/interaction/user-questions/src/types.ts) diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index e9a309e576..cf675b2162 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 7706a326154c375afe316b640d48003345f4bef5 -workspace.zh.md: d621baf15487138aa837a4ad366a8d451f0886cc +workspace.md: bf2ba88b84b65cdd289f4bd603354dbd7027b239 +workspace.zh.md: a1a190c2c4c006d067620ab6d8494cba947b0368 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 7706a32615..bf2ba88b84 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -149,6 +149,65 @@ abstract capability(): DirectoryPickerCapability Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) + + +### `ctx.workspaceController` — `WorkspaceController` + +Host service backing the generated `ctx.remote.workspace` namespace. + +```ts cordis-catalog +/** + * Create or idempotently resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ +@Remote('create') create(request: WorkspaceCreateRequest): Promise + +/** + * Rename one Workspace to a unique non-blank title. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ +@Remote('rename') rename(request: WorkspaceRenameRequest): Promise + +/** + * Remove one Workspace registration while retaining files and Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ +@Remote('delete') delete(request: WorkspaceDeleteRequest): Promise + +/** + * Move one Workspace within the registry display order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ +@Remote('insertBefore') insertBefore(request: WorkspaceInsertBeforeRequest): Promise + +/** + * Move one accounted Session within a Workspace. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ +@Remote('insertSessionBefore') insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise + +/** + * Hide one known Session from Workspace grouping surfaces. + * @param request - Session identity to archive. + * @returns the complete resulting archive set. + */ +@Remote('archiveSession') archiveSession(request: WorkspaceArchiveSessionRequest): Promise + +/** + * Stream a complete Workspace baseline followed by ordered increments. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ +@Remote({ mode: 'stream' }) follow(signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) + ### `ctx.workspaceRegistry` — `WorkspaceRegistry` diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index d621baf154..a1a190c2c4 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -149,6 +149,65 @@ abstract capability(): DirectoryPickerCapability Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) + + +### `ctx.workspaceController` — `WorkspaceController` + +Host service backing the generated `ctx.remote.workspace` namespace. + +```ts cordis-catalog +/** + * Create or idempotently resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ +@Remote('create') create(request: WorkspaceCreateRequest): Promise + +/** + * Rename one Workspace to a unique non-blank title. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ +@Remote('rename') rename(request: WorkspaceRenameRequest): Promise + +/** + * Remove one Workspace registration while retaining files and Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ +@Remote('delete') delete(request: WorkspaceDeleteRequest): Promise + +/** + * Move one Workspace within the registry display order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ +@Remote('insertBefore') insertBefore(request: WorkspaceInsertBeforeRequest): Promise + +/** + * Move one accounted Session within a Workspace. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ +@Remote('insertSessionBefore') insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise + +/** + * Hide one known Session from Workspace grouping surfaces. + * @param request - Session identity to archive. + * @returns the complete resulting archive set. + */ +@Remote('archiveSession') archiveSession(request: WorkspaceArchiveSessionRequest): Promise + +/** + * Stream a complete Workspace baseline followed by ordered increments. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ +@Remote({ mode: 'stream' }) follow(signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) + ### `ctx.workspaceRegistry` — `WorkspaceRegistry` diff --git a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts index 27efcf5e55..26905eaade 100644 --- a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts +++ b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts @@ -1,17 +1,15 @@ import type { Context } from '@deepseek-ai/cordis' import '@deepseek-ai/dsh-user-questions' -/** Snapshot-only provider whose invocation means the child guard failed. */ +/** Snapshot-only answerer whose invocation means the child guard failed. */ export const name = 'child-question-tripwire' -/** User-interaction service required by the tripwire provider. */ +/** User-interaction service required by the tripwire answerer. */ export const inject = ['userQuestions'] -/** Register a provider that must remain unreachable for the delegated call. */ +/** Register an answerer that must remain unreachable for the delegated call. */ export function apply(ctx: Context): void { - ctx.userQuestions.registerProvider({ - async ask() { - throw new Error('snapshot tripwire: delegated question reached the UI provider') - }, + ctx.on('user-questions/request', async () => { + throw new Error('snapshot tripwire: delegated question reached the UI answerer') }) } diff --git a/knip.json b/knip.json index 439371c68b..b457dd54f3 100644 --- a/knip.json +++ b/knip.json @@ -115,9 +115,35 @@ "project": [ "src/**/*.ts", "tests/**/*.ts" - ], + ] + }, + "packages/api/workspace-controller": { "ignoreDependencies": [ - "@deepseek-ai/dsh-api-gateway" + "zod" + ] + }, + "packages/client/ui-approval": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.{ts,tsx}", + "tests/**/*.tsx" + ] + }, + "packages/client/ui-conversation": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-client-ui-workspace" + ] + }, + "packages/client/ui-sidebar": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-client-ui-workspace" + ] + }, + "packages/client/ui-subagent": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-client-ui-input-trigger" ] }, "packages/client/ui-primitives": { @@ -624,6 +650,7 @@ "tests/**/*.ts" ], "ignoreDependencies": [ + "@deepseek-ai/dsh-client-store", "@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-slots", "@types/react", diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 1fe44ec7c0..cfeead8e18 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: 7caf707c376bd3e2654fad1f0c01ac83e6faa44c -README.zh.md: ce3d34480d2a680d0a9ec6be621c3adad7e0ec19 +README.md: 2546b0c4e54ea106c9203ce419027fe8253c7ca5 +README.zh.md: 9281519cda422137c6fd08ff6680ba0d57902913 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 7caf707c37..2546b0c4e5 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -2,25 +2,31 @@ English | [中文](README.zh.md) -Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. +Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams. ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) `ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. + +Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready. + ## Client service: `ClientRemote` (ctx key: `remote`) `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. -`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. Delivery is one-way and follows registration order; a listener that throws is logged and isolated from the remaining listeners, which never affects the frame pump. `ctx.remote.$dispatch()` is the other half of that surface, and it is the carrier's: the Client half owning the Host frame sink hands each decoded frame over, and an event name nobody subscribes to is dropped, since the wire carries whatever the Host selected. A consumer subscribes and never calls it. +`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas, while `RemoteJournalStream` adds follow-before-page opening, cursor deduplication, pagination, reconnect catch-up, and gap repair. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. + +`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether or not any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the `ready` item and `host.describe` jointly establish a Connection generation. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. Generated declaration merges provide the TypeScript API through the shared `TypertClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. @@ -37,6 +43,6 @@ No direct effect; invoked business Services own any model-visible result. - The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypertLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers. - SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. - Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. -- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. +- `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. -- Forwarded events reach `$on` exactly as the Host emitted them: no payload projection or redaction, no Scope-bound subscription, and no replay after a reconnect. +- Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index ce3d34480d..9281519cda 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -2,25 +2,31 @@ [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 +为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流。 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) 每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context adapter 解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 + +Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。 + ## Client 服务:`ClientRemote`(ctx key:`remote`) `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。投递是单向的,并按注册顺序进行;抛错的 listener 会被记录并与其余 listener 隔离,绝不影响帧泵。`ctx.remote.$dispatch()` 是该面的另一半,且属于载体:持有 Host 帧 sink 的 Client 半把每个解码后的帧交进来,收到无人订阅的事件名即丢弃,因为 wire 上出现什么取决于 Host 的转发选择。消费方只订阅,绝不调用它。 +`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成,`RemoteJournalStream` 则提供 follow-before-page、cursor 去重、分页、重连追赶与缺口修复。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 + +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;`ready` 项与 `host.describe` 共同建立一个 Connection generation。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 生成的声明合并通过共享的 `TypertClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 @@ -37,6 +43,6 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle - Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypertLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 -- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 +- `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 -- 被转发的事件原样到达 `$on`:没有载荷投影或脱敏,不支持 Scope 化订阅,重连后也不重放。 +- 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 775c95e999..e74d19946f 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -56,19 +56,25 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "ws": "^8.21.0" }, "peerDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:^", "zod": "^4.4.3" } diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 31e3db6711..4fe46e6d2d 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,10 +5,13 @@ */ import { Service } from '@deepseek-ai/cordis' -import type { Context, Events } from '@deepseek-ai/cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { Context } from '@deepseek-ai/cordis' +import type { + ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + TypertClientEventListener, TypertClientRemote, RemoteResult, TypertCodec, @@ -16,6 +19,29 @@ import type { TypertRemoteContribution, TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' +import { + RemoteStreamCarrierError, + RemoteStreamError, + RemoteStreamMuxClient, +} from './stream-client.ts' +import { ClientRemoteEvents } from './remote-events.ts' +import { + RemoteStream, + type RemoteStreamOptions, +} from './remote-stream.ts' + +export { RemoteStreamCarrierError, RemoteStreamError } from './stream-client.ts' +export { RemoteJournalStream } from './journal-stream.ts' +export type { + RemoteJournalChange, + RemoteJournalFrame, + RemoteJournalStreamOptions, + RemoteStreamFactory, +} from './journal-stream.ts' +export { RemoteStream } from './remote-stream.ts' +export type { RemoteStreamItem, RemoteStreamOptions } from './remote-stream.ts' +export { RemoteSnapshotStream } from './snapshot-stream.ts' +export type { RemoteSnapshotStreamOptions } from './snapshot-stream.ts' interface MountToken { active: boolean @@ -47,11 +73,21 @@ interface BoundContextIdentity { readonly value: unknown } +interface PreparedClientInvocation { + readonly endpoint: string + readonly args: Readonly> + readonly signal: AbortSignal +} + interface RemoteNamespaceHandle { readonly service: RemoteNamespaceService readonly dispose: TypertDisposer } +interface LoaderReadiness { + await(): Promise +} + /** One descriptor's mounted variants, for the group disposer to unwind. */ interface InstalledMethod { readonly descriptor: InvocationDescriptor @@ -60,8 +96,15 @@ interface InstalledMethod { scoped: boolean } -/** Typed Remote service augmented by generated direct namespaces. */ -export type ClientRemote = TypertClientRemote +/** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */ +export interface ClientRemote extends TypertClientRemote { + /** + * Create one independently cancellable, reconnecting logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a single-consumer stream annotated with physical generation ids. + */ + $stream(options: RemoteStreamOptions): RemoteStream +} declare module '@deepseek-ai/cordis' { interface Context { @@ -81,28 +124,46 @@ export function apply(ctx: Context): void { new ClientRemoteService(ctx) } -/** One subscribed listener after `$on` erased its per-event argument list. */ -type RemoteEventListener = (...args: never[]) => void - -/** - * One subscription, identified by the registration rather than by its listener: - * two fibers may subscribe the same function object to the same event, and each - * disposer must retire only its own registration. - */ -interface RemoteEventSubscription { - readonly listener: RemoteEventListener -} - -class ClientRemoteService extends Service implements TypertClientRemote { +class ClientRemoteService extends Service implements ClientRemote { private readonly ownerCtx: Context + private readonly connection: ConnectionHandle private readonly namespaces = new Map() - private readonly subscriptions = new Map() + private readonly streams = new RemoteStreamMuxClient() + private readonly events: ClientRemoteEvents private mutations = Promise.resolve() constructor(ctx: Context) { super(ctx, 'remote') this.ownerCtx = ctx - ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions') + const connection = ctx.get('connection') as ConnectionHandle + this.connection = connection + this.events = new ClientRemoteEvents( + ctx, + connection, + (endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal), + ) + if (connection.rpc.open === undefined) this.streams.start() + let disposed = false + let loop: ReturnType | undefined + const start = (): void => { + if (disposed) return + loop = connection.start({ + onConnected: () => { this.ownerCtx.emit('connection/reset') }, + }) + } + const loader = ctx.get('loader') as LoaderReadiness | undefined + if (loader === undefined) start() + else void loader.await().then(start, () => {}) + ctx.effect(() => async () => { + disposed = true + loop?.stop() + await this.events.dispose() + await this.streams.close() + }, 'api-gateway.client.transport') + } + + $stream(options: RemoteStreamOptions): RemoteStream { + return new RemoteStream(this.connection, options) } async $mount(contribution: TypertRemoteContribution): ReturnType { @@ -117,60 +178,24 @@ class ClientRemoteService extends Service implements TypertClientRemote { $on( event: Event, - listener: Events[Event], - ): ReturnType { - // The table is keyed by the runtime event name, so the argument list this - // signature pins per event cannot survive in it; `$deliver` restores it - // from the frame the Host emitted for that same name. - const subscription: RemoteEventSubscription = { listener } - const owned = this.ctx.effect(() => { - const listeners = this.listeners(event) - listeners.push(subscription) - return () => { - const at = listeners.indexOf(subscription) - /* v8 ignore next -- listener */ - if (at >= 0) listeners.splice(at, 1) - } - }, `api-gateway.client.$on(${JSON.stringify(event)})`) - return () => { void owned() } + listener: TypertClientEventListener, + ): () => void { + return this.events.subscribe(this.ctx, event, listener) } - /** - * Deliver one forwarded event in registration order, isolating a listener - * that fails either synchronously or by rejecting a returned promise; see - * {@link TypertClientRemote.$dispatch} for the caller contract. - */ - $dispatch(event: string, args: readonly unknown[]): void { - const listeners = this.subscriptions.get(event) - if (listeners === undefined) return - // Snapshot: a listener may subscribe or dispose during delivery, and this - // round's recipients are the ones registered when the frame arrived. - for (const { listener } of [...listeners]) { - const report = (error: unknown): void => { - console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error) - } - try { - /* oxlint-disable-next-line typescript/no-confusing-void-expression -- - * The declared return is void, so nobody awaits an async listener; the - * runtime value is still a promise, and reading it is the only way to - * keep its rejection inside this containment instead of surfacing as an - * unhandled one. */ - const settled: unknown = listener(...args as never[]) - if (settled instanceof Promise) settled.catch(report) - } catch (error) { - report(error) - } - } - } - - /** Subscriptions for one event name; empty arrays are retained, bounded by the Host's selection. */ - private listeners(event: string): RemoteEventSubscription[] { - let listeners = this.subscriptions.get(event) - if (listeners === undefined) { - listeners = [] - this.subscriptions.set(event, listeners) - } - return listeners + /** Open one Remote stream and normalize a worker-local carrier's structural failures. */ + private openRemoteStream( + endpoint: string, + payload: unknown, + signal: AbortSignal, + noConnection = `client api: ${endpoint} has no active Connection`, + ): AsyncIterable { + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error(noConnection) + const local = connection.rpc.open?.('/api', endpoint, payload, signal) + return local === undefined + ? this.streams.open(endpoint, payload, signal) + : normalizeConnectionStream(local) } private enqueue(operation: () => T | Promise): Promise { @@ -331,12 +356,12 @@ class ClientRemoteService extends Service implements TypertClientRemote { scoped: ScopedMethod | undefined, callerCtx: Context, values: readonly unknown[], - ): Promise> { + ): Promise> | AsyncIterable { if (scoped !== undefined) { - const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) - const identity = binder?.identity(callerCtx) + const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) + const identity = adapter?.identity(callerCtx) if (identity !== undefined) { - return this.invoke( + return this.invokeSelected( scoped.descriptor, scoped.projection, scoped.token, @@ -347,14 +372,28 @@ class ClientRemoteService extends Service implements TypertClientRemote { } } if (direct !== undefined) { - return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values) + return this.invokeSelected(direct.descriptor, undefined, direct.token, callerCtx, values) } if (scoped !== undefined) { - return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) + return this.invokeSelected(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) } throw new Error('client api: Remote method is no longer mounted') } + private invokeSelected( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): Promise> | AsyncIterable { + if (descriptor.mode === 'stream') { + return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity) + } + return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) + } + private async invoke( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, @@ -365,6 +404,48 @@ class ClientRemoteService extends Service implements TypertClientRemote { ): Promise> { const endpoint = endpointOf(descriptor) if (!token.active) return withdrawn(endpoint) + const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) + try { + const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal) + if (!mountActive(token)) return withdrawn(endpoint) + if (!result.ok) return { ok: false, error: result.error } + return { ok: true, value: result.value } + } catch (error) { + // Carrier throws (offline or abort) are outcomes of the call, not assembly + // faults, so they join the same error branch. + return carrierFailure(endpoint, error) + } + } + + private async *invokeStream( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): AsyncGenerator { + const endpoint = endpointOf(descriptor) + if (!token.active) throw new Error(withdrawn(endpoint).error.message) + const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) + const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal) + for await (const value of stream) { + if (!mountActive(token)) throw new Error(withdrawn(endpoint).error.message) + yield value + } + } + + private prepareInvocation( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): PreparedClientInvocation { + const endpoint = endpointOf(descriptor) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 if (values.length !== expected && !hasCallerSignal) { @@ -377,43 +458,32 @@ class ClientRemoteService extends Service implements TypertClientRemote { } const args = Object.create(null) as Record if (projection !== undefined) { - const binder = boundIdentity === undefined + const adapter = boundIdentity === undefined ? this.ownerCtx.typert.contexts.getClient(projection.context) : undefined - if (boundIdentity === undefined && binder === undefined) { - throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) + if (boundIdentity === undefined && adapter === undefined) { + throw new Error(`client api: ${endpoint} has no Client Context adapter for ${JSON.stringify(projection.context)}`) } const identity = boundIdentity === undefined - ? binder?.identity(callerCtx) + ? adapter?.identity(callerCtx) : boundIdentity.value if (identity === undefined) { throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) } - args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire) + args[projection.wire] = parseInput(projection.codec, identity, endpoint, projection.wire) } let valueIndex = 0 descriptor.parameters.forEach((parameter, parameterIndex) => { if (parameterIndex === projection?.parameterIndex) return - const value = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) + const value = parseInput(parameter.codec, values[valueIndex], endpoint, parameter.wire) if (value !== undefined) args[parameter.wire] = value valueIndex += 1 }) - const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined - if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined const signal = callerSignal === undefined ? token.abort.signal : AbortSignal.any([token.abort.signal, callerSignal]) - try { - const result = await connection.rpc.call('/api', endpoint, { args }, signal) - if (!mountActive(token)) return withdrawn(endpoint) - if (!result.ok) return { ok: false, error: result.error } - return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') } - } catch (error) { - // Carrier throws (offline, abort, a rejected result payload) are outcomes - // of the call, not assembly faults, so they join the same error branch. - return carrierFailure(endpoint, error) - } + return { endpoint, args, signal } } } @@ -422,7 +492,7 @@ type InvokeRemote = ( scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], -) => Promise> +) => Promise> | AsyncIterable class RemoteNamespaceService extends Service { private readonly methods = new Map() @@ -477,7 +547,7 @@ class RemoteNamespaceService extends Service { Object.defineProperty(this, method, { configurable: true, enumerable: true, - get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise> { + get: function (this: RemoteNamespaceService): (...args: unknown[]) => unknown { const callerCtx = this.ctx const current = this.methods.get(method) const direct = current?.direct @@ -593,7 +663,6 @@ function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | function requireStrictDescriptor(descriptor: InvocationDescriptor): void { const endpoint = endpointOf(descriptor) - requireStrictCodec(descriptor.result, endpoint, 'result') for (const parameter of descriptor.parameters) { requireStrictCodec(parameter.codec, endpoint, parameter.wire) } @@ -608,7 +677,7 @@ function requireStrictCodec(codec: TypertCodec, endpoint: string, field: string) } } -function parse(codec: TypertCodec, value: unknown, endpoint: string, field: string): unknown { +function parseInput(codec: TypertCodec, value: unknown, endpoint: string, field: string): unknown { if (codec.mode !== 'strict') { throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`) } @@ -620,14 +689,37 @@ function parse(codec: TypertCodec, value: unknown, endpoint: string, field: stri } /** The namespace retired before or during the call, so no request outcome exists. */ -function withdrawn(endpoint: string): RemoteResult { +function withdrawn(endpoint: string): Extract, { readonly ok: false }> { return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`) } -function carrierFailure(endpoint: string, error: unknown): RemoteResult { +function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`) } -function internalFailure(message: string): RemoteResult { +function internalFailure(message: string): Extract, { readonly ok: false }> { return { ok: false, error: { code: 'internal', message, details: {} } } } + +type MarkedConnectionStreamFailure = Error & { + readonly dshRemoteStreamFailure?: + | { readonly kind: 'remote'; readonly code: string; readonly details: object } + | { readonly kind: 'carrier' } +} + +/** Preserve Gateway error classes across a worker transport's separately bundled page half. */ +async function *normalizeConnectionStream(source: AsyncIterable): AsyncGenerator { + try { + yield * source + } catch (error) { + if (!(error instanceof Error)) throw error + const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure + if (marker?.kind === 'remote') { + throw new RemoteStreamError(marker.code, error.message, marker.details) + } + if (marker?.kind === 'carrier') { + throw new RemoteStreamCarrierError(error.message, { cause: error }) + } + throw error + } +} diff --git a/packages/api/gateway/src/client/journal-stream.ts b/packages/api/gateway/src/client/journal-stream.ts new file mode 100644 index 0000000000..f21e79f241 --- /dev/null +++ b/packages/api/gateway/src/client/journal-stream.ts @@ -0,0 +1,522 @@ +/** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */ + +import { RemoteStreamCarrierError } from './stream-client.ts' +import type { + RemoteStream, + RemoteStreamItem, + RemoteStreamOptions, +} from './remote-stream.ts' + +/** Transport-neutral opening cursor or journal entry. */ +export type RemoteJournalFrame = + | { readonly type: 'opened'; readonly cursor: Cursor } + | { readonly type: 'entry'; readonly entry: Entry } + +/** One committed journal-window update. */ +export type RemoteJournalChange = + | { + readonly type: 'replace' + readonly page: Page + readonly entries: readonly Entry[] + readonly hasMore: boolean + } + | { + readonly type: 'prepend' + readonly page: Page + readonly entries: readonly Entry[] + readonly hasMore: boolean + } + | { readonly type: 'append'; readonly entry: Entry } + +type JournalStreamItem = RemoteStreamItem> + +/** Gateway capability used to create one reconnecting Remote stream. */ +export interface RemoteStreamFactory { + /** + * Create one independently cancellable logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a reconnecting single-consumer stream. + */ + $stream(options: RemoteStreamOptions): RemoteStream +} + +/** Domain publication and cursor operations for one addressed journal stream. */ +export interface RemoteJournalStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string + /** Cursor representing a journal with no entries. */ + readonly emptyCursor: Cursor + /** Read the ordered entries carried by a page. */ + readonly entries: (page: Page) => readonly Entry[] + /** Read whether an older page exists. */ + readonly hasMore: (page: Page) => boolean + /** Read one entry's durable cursor. */ + readonly cursor: (entry: Entry) => Cursor + /** Compare two cursors. */ + readonly compare: (left: Cursor, right: Cursor) => number + /** Test whether the right cursor immediately follows the left cursor. */ + readonly follows: (left: Cursor, right: Cursor) => boolean + /** Apply one complete journal-window change. */ + readonly publish: (change: RemoteJournalChange) => void + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void + /** Publish a terminal stream, page, or protocol failure after opening. */ + readonly failed: (error: unknown) => void +} + +/** + * Owns follow-before-page opening, ordered live delivery, pagination, and repair. + * + * The domain retains its published window during reconnection. A replacement is + * published only after a tail page reaches the generation's opening cursor. + */ +export abstract class RemoteJournalStream { + private readonly stream: RemoteStream> + private initialRequest!: PageRequest + private resumeCursor: Cursor | undefined + private hasResumeCursor = false + private generation = 0 + private firstCursor: Cursor | undefined + private lastCursor: Cursor | undefined + private started = false + private opened = false + private disposed = false + private done: Promise | undefined + private closing: Promise | undefined + private pendingNext: Promise>> | undefined + + /** + * @param remote - Gateway factory for the reconnecting physical-generation stream. + * @param options - cursor algebra and domain publication sinks. + */ + protected constructor( + remote: RemoteStreamFactory, + private readonly options: RemoteJournalStreamOptions, + ) { + this.stream = remote.$stream>({ + name: options.name, + open: signal => this.follow( + this.hasResumeCursor ? this.resumeCursor : undefined, + signal, + ), + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) + : new Error( + `${this.hasResumeCursor ? 'resumed ' : ''}${options.name} ended before its opening cursor`, + ), + ...(options.carrierFailed === undefined + ? {} + : { carrierFailed: options.carrierFailed }), + }) + } + + /** + * Open one physical journal generation after the last accepted cursor. + * @param after - last accepted cursor, or `undefined` for the initial generation. + * @param signal - cancellation lifetime of the physical generation. + * @returns opening cursor followed by live entries. + */ + protected abstract follow( + after: Cursor | undefined, + signal: AbortSignal, + ): AsyncIterable> + + /** + * Read one journal page through the addressed domain source. + * @param request - domain page request. + * @param through - inclusive journal cursor that fixes the source read. + * @param signal - cancellation lifetime shared with the logical stream. + * @returns the requested page, whose tail equals `through` unless the domain request selects older entries. + */ + protected abstract readPage(request: PageRequest, through: Cursor, signal: AbortSignal): Promise + + /** + * Derive an unbounded-tail request from the initial page request. + * @param initial - request used to open the journal window. + * @returns request suitable for reconnect and gap repair. + */ + protected abstract repairRequest(initial: PageRequest): PageRequest + + /** Cancellation lifetime shared by follow and page calls. */ + get signal(): AbortSignal { + return this.stream.signal + } + + /** + * Establish follow before reading and publishing the initial page. + * @param request - initial tail-page request. + * @returns after the first complete window is published. + */ + async open(request: PageRequest): Promise { + if (this.started) throw new Error(`${this.options.name} already opened`) + this.started = true + this.initialRequest = request + const iterator = this.stream[Symbol.asyncIterator]() + try { + const first = await this.takeNext(iterator) + if (first.done) throw new Error(`${this.options.name} ended before its opening cursor`) + await this.replaceGeneration(request, first.value, iterator, false) + this.opened = true + this.done = this.consume(iterator) + } catch (error) { + await this.stream.dispose() + throw error + } + } + + /** + * Read and prepend one older page after a successful open. + * @param request - domain page request bound to this stream's address. + * @returns after the page is applied or rejected as discontinuous. + */ + async prepend(request: PageRequest): Promise { + if (!this.opened || this.disposed) throw new Error(`${this.options.name} is not open`) + const page = await this.readPage(request, this.currentCursor(), this.stream.signal) + this.stream.signal.throwIfAborted() + const entries = this.options.entries(page) + this.assertPage(entries) + const before = this.firstCursor + const accepted = before === undefined + ? [...entries] + : entries.filter(entry => this.options.compare(this.options.cursor(entry), before) < 0) + const tail = accepted.at(-1) + if (tail !== undefined && before !== undefined + && !this.options.follows(this.options.cursor(tail), before)) { + this.options.publish({ type: 'prepend', page, entries: [], hasMore: false }) + throw new Error(`${this.options.name} history page is discontinuous`) + } + const first = accepted[0] + if (first !== undefined) this.firstCursor = this.options.cursor(first) + this.options.publish({ + type: 'prepend', + page, + entries: accepted, + hasMore: this.options.hasMore(page), + }) + } + + /** Replace the active physical generation while retaining the published window. */ + restart(): void { + this.stream.restart() + } + + /** + * Permanently stop follow, page requests, and the background consumer. + * @returns when no stream work or publication callback can still run. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing + this.disposed = true + const done = this.done + const closing = (async () => { + await this.stream.dispose() + await done + })() + this.closing = closing + return closing + } + + private async consume( + iterator: AsyncIterator>, + ): Promise { + try { + while (true) { + const next = await this.takeNext(iterator) + if (next.done) return + const item = next.value + if (item.generation !== this.generation) { + await this.replaceGeneration(this.repairPageRequest(), item, iterator, true) + continue + } + if (item.value.type === 'opened') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + await this.acceptEntry(item.value.entry, item, iterator) + } + } catch (error) { + if (!this.disposed) this.options.failed(error) + } + } + + private async replaceGeneration( + request: PageRequest, + initial: JournalStreamItem, + iterator: AsyncIterator>, + resumed: boolean, + ): Promise { + let item = initial + let isResumed = resumed + while (true) { + const cursor = this.opening(item, isResumed) + this.setResumeCursor(cursor) + const superseded = await this.replaceThrough( + request, + cursor, + item.generation, + item.signal, + iterator, + [], + ) + if (superseded === undefined) return + item = superseded + isResumed = true + } + } + + private opening( + item: RemoteStreamItem>, + resumed: boolean, + ): Cursor { + if (item.value.type !== 'opened') { + throw new Error(`${resumed ? 'resumed ' : ''}${this.options.name} emitted an entry before its opening cursor`) + } + const cursor = item.value.cursor + if (resumed && this.lastCursor !== undefined + && this.options.compare(cursor, this.lastCursor) < 0) { + throw new Error( + `${this.options.name} resumed at a cursor behind the last applied entry`, + ) + } + this.generation = item.generation + item.accept() + return cursor + } + + private async acceptEntry( + entry: Entry, + item: JournalStreamItem, + iterator: AsyncIterator>, + ): Promise { + const cursor = this.options.cursor(entry) + const last = this.lastCursor as Cursor + if (this.options.compare(cursor, last) <= 0) return + if (!this.options.follows(last, cursor)) { + const request = this.repairPageRequest() + const superseded = await this.replaceThrough( + request, + cursor, + item.generation, + item.signal, + iterator, + [entry], + ) + if (superseded !== undefined) { + await this.replaceGeneration(request, superseded, iterator, true) + } + return + } + if (this.firstCursor === undefined) this.firstCursor = cursor + this.lastCursor = cursor + this.setResumeCursor(cursor) + this.options.publish({ type: 'append', entry }) + } + + private async replaceThrough( + request: PageRequest, + requiredCursor: Cursor, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + ): Promise | undefined> { + let read = await this.readPageWhileFollowing( + request, + requiredCursor, + generation, + signal, + iterator, + queued, + ) + if (read.type === 'superseded') return read.item + let page = read.page + this.assertPageThrough(page, requiredCursor) + let entries = this.mergeReplacement(page, queued) + let target = this.maxCursor(requiredCursor, queued) + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + read = await this.readPageWhileFollowing( + this.repairPageRequest(), + target, + generation, + signal, + iterator, + queued, + ) + if (read.type === 'superseded') return read.item + page = read.page + this.assertPageThrough(page, target) + entries = this.mergeReplacement(page, queued) + target = this.maxCursor(requiredCursor, queued) + } + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + throw new Error(`${this.options.name} page did not reach its opening cursor`) + } + const first = entries[0] + this.firstCursor = first === undefined ? undefined : this.options.cursor(first) + this.lastCursor = this.tailCursor(entries) + this.setResumeCursor(this.lastCursor) + this.options.publish({ + type: 'replace', + page, + entries, + hasMore: this.options.hasMore(page), + }) + return undefined + } + + private async readPageWhileFollowing( + request: PageRequest, + through: Cursor, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + ): Promise< + | { readonly type: 'page'; readonly page: Page } + | { readonly type: 'superseded'; readonly item: JournalStreamItem } + > { + const page = this.readPage(request, through, signal).then( + value => ({ type: 'page' as const, value }), + (error: unknown) => ({ type: 'page-error' as const, error }), + ) + while (true) { + const pending = this.nextResult(iterator) + const next = pending.then( + value => ({ type: 'next' as const, value }), + (error: unknown) => ({ type: 'next-error' as const, error }), + ) + const result = await Promise.race([page, next]) + if (result.type === 'page') { + signal.throwIfAborted() + return { type: 'page', page: result.value } + } + if (result.type === 'page-error') { + if (!signal.aborted || this.stream.signal.aborted) throw result.error + return this.awaitReplacementGeneration(generation, iterator, pending) + } + this.releaseNext() + if (result.type === 'next-error') throw result.error + if (result.value.done) { + signal.throwIfAborted() + throw new Error(`${this.options.name} ended while reading its replacement page`) + } + const item = result.value.value + if (item.generation !== generation) return { type: 'superseded', item } + if (item.value.type === 'opened') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + queued.push(item.value.entry) + } + } + + private async awaitReplacementGeneration( + generation: number, + iterator: AsyncIterator>, + initial: Promise>>, + ): Promise<{ readonly type: 'superseded'; readonly item: JournalStreamItem }> { + let pending = initial + while (true) { + let next: IteratorResult> + try { + next = await pending + } finally { + this.releaseNext() + } + if (next.done) { + this.stream.signal.throwIfAborted() + throw new Error(`${this.options.name} ended while replacing an aborted page generation`) + } + const item = next.value + if (item.generation !== generation) return { type: 'superseded', item } + if (item.value.type === 'opened') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + pending = this.nextResult(iterator) + } + } + + private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined { + const entries = [...this.options.entries(page)] + this.assertPage(entries) + const sorted = [...queued].sort((left, right) => ( + this.options.compare(this.options.cursor(left), this.options.cursor(right)) + )) + let tail = this.tailCursor(entries) + for (const entry of sorted) { + const cursor = this.options.cursor(entry) + if (this.options.compare(cursor, tail) <= 0) continue + if (!this.options.follows(tail, cursor)) return undefined + entries.push(entry) + tail = cursor + } + return entries + } + + private maxCursor(cursor: Cursor, entries: readonly Entry[]): Cursor { + let result = cursor + for (const entry of entries) { + const candidate = this.options.cursor(entry) + if (this.options.compare(candidate, result) > 0) result = candidate + } + return result + } + + private nextResult( + iterator: AsyncIterator>, + ): Promise>> { + this.pendingNext ??= iterator.next() + return this.pendingNext + } + + private async takeNext( + iterator: AsyncIterator>, + ): Promise>> { + const pending = this.nextResult(iterator) + try { + return await pending + } finally { + this.releaseNext() + } + } + + private releaseNext(): void { + this.pendingNext = undefined + } + + private repairPageRequest(): PageRequest { + return this.repairRequest(this.initialRequest) + } + + private setResumeCursor(cursor: Cursor): void { + this.resumeCursor = cursor + this.hasResumeCursor = true + } + + private currentCursor(): Cursor { + return this.resumeCursor as Cursor + } + + private tailCursor(entries: readonly Entry[]): Cursor { + const tail = entries.at(-1) + return tail === undefined ? this.options.emptyCursor : this.options.cursor(tail) + } + + private assertPage(entries: readonly Entry[]): void { + const iterator = entries[Symbol.iterator]() + const first = iterator.next() + if (first.done) return + let previous = first.value + for (const entry of iterator) { + if (!this.options.follows(this.options.cursor(previous), this.options.cursor(entry))) { + throw new Error(`${this.options.name} page contains discontinuous entries`) + } + previous = entry + } + } + + private assertPageThrough(page: Page, through: Cursor): void { + const tail = this.tailCursor(this.options.entries(page)) + if (this.options.compare(tail, through) !== 0) { + throw new Error(`${this.options.name} page did not end at its requested cursor`) + } + } +} diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts new file mode 100644 index 0000000000..341c1f3f4d --- /dev/null +++ b/packages/api/gateway/src/client/remote-events.ts @@ -0,0 +1,339 @@ +/** Client owner for forwarded Remote Event subscriptions and deliveries. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { + ConnectionGenerationSource, + ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' +import type { + TypertClientEventListener, + TypertRemoteEvent, +} from '@deepseek-ai/dsh-typert-protocol' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + isRemoteEventAgentId, + isRemoteEventClientId, + isRemoteEventId, + isRemoteJsonValue, + projectRemoteEventRejection, + type RemoteEventClientId, + type RemoteEventDownlinkFrame, + type RemoteEventEmitFrame, + type RemoteEventInvocationFrame, + type RemoteEventResult, +} from '../stream-protocol.ts' + +/** Open the Gateway-internal forwarded-event stream on the selected carrier. */ +export type RemoteEventStreamOpener = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => AsyncIterable + +/** One subscribed listener after its event-specific signature is erased. */ +type RemoteEventListener = (this: Context, ...args: unknown[]) => unknown + +/** Untyped access used only for instance-private Cordis event keys. */ +interface PrivateEventContext { + on(name: string, listener: RemoteEventListener): () => boolean + parallel(name: string, ...args: unknown[]): Promise + waterfall( + thisArg: Context, + name: string, + request: Readonly>, + next: () => Promise, + ): unknown +} + +/** Transport outcome after one Client listener chain either claims or delegates. */ +type RemoteEventReplyOutcome = + | { readonly kind: 'result'; readonly value: unknown } + | { readonly kind: 'next' } + | { readonly kind: 'rejected'; readonly error: ReturnType } + +/** Private end-of-chain marker that cannot collide with a JSON listener result. */ +const REMOTE_EVENT_NEXT = Symbol('api-gateway.remote-event.next') + +/** Own Cordis registrations, generation pumping, waterfall dispatch, and HTTP replies. */ +export class ClientRemoteEvents { + private readonly eventPrefix = `internal/api-gateway/remote-event/${randomUUID()}/` + private readonly unregisterGeneration: () => void + private activeGeneration: Promise | undefined + + /** + * @param ownerCtx - Client Gateway root used for Agent Context resolution. + * @param connection - Connection carrier used for HTTP result calls. + * @param openStream - selected in-process or WebSocket stream opener. + */ + constructor( + private readonly ownerCtx: Context, + private readonly connection: ConnectionHandle, + private readonly openStream: RemoteEventStreamOpener, + ) { + this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration) + } + + /** + * Register one typed Remote Event listener in its calling fiber. + * @param callerCtx - fiber Context owning the registration. + * @param event - selected forwarded event. + * @param listener - listener derived from that event's declaration. + * @returns disposer for this exact registration. + */ + subscribe( + callerCtx: Context, + event: Event, + listener: TypertClientEventListener, + ): () => void { + const dispose = privateEvents(callerCtx).on( + this.eventKey(event), + listener as unknown as RemoteEventListener, + ) + return () => { dispose() } + } + + /** Withdraw the generation source and wait for active listener work to quiesce. */ + async dispose(): Promise { + this.unregisterGeneration() + await Promise.allSettled([this.activeGeneration]) + } + + /** Track the current generation so plugin disposal waits for listener work to stop. */ + private readonly runGeneration: ConnectionGenerationSource = (signal, ready) => { + const tracked = this.pumpEvents(signal, ready).finally(() => { + if (this.activeGeneration === tracked) this.activeGeneration = undefined + }) + this.activeGeneration = tracked + return tracked + } + + /** Deliver one notification through Cordis while containing listener failures. */ + private deliver(frame: RemoteEventEmitFrame): void { + void privateEvents(this.ownerCtx) + .parallel(this.eventKey(frame.event), ...frame.args) + .catch((error: unknown) => { this.reportError(frame.event, error) }) + } + + /** Run one Connection generation over the forwarded-event logical stream. */ + private async pumpEvents(signal: AbortSignal, ready: () => void): Promise { + let clientId: RemoteEventClientId | undefined + const failed = new AbortController() + const generationSignal = AbortSignal.any([signal, failed.signal]) + const active = new Map() + const tasks = new Set>() + const source = this.openStream( + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + generationSignal, + ) + let streamFailed = false + let streamError: unknown + try { + for await (const value of source) { + if (clientId === undefined) { + clientId = parseRemoteEventReady(value) + ready() + continue + } + const frame = parseRemoteEventFrame(value) + if (frame.type === 'cancel') { + active.get(frame.eventId)?.abort(new Error('client api: Remote event was cancelled by the Host')) + continue + } + if (frame.type === 'emit') { + this.deliver(frame) + continue + } + const controller = new AbortController() + active.set(frame.eventId, controller) + const deliverySignal = AbortSignal.any([generationSignal, controller.signal]) + const task = this.answer(frame, clientId, deliverySignal) + .catch((error: unknown) => { + if (!deliverySignal.aborted) failed.abort(error) + }) + .finally(() => { + active.delete(frame.eventId) + tasks.delete(task) + }) + tasks.add(task) + } + } catch (error) { + streamFailed = true + streamError = error + } finally { + for (const controller of active.values()) { + controller.abort(new Error('client api: Remote event generation ended')) + } + await Promise.allSettled(tasks) + } + if (failed.signal.aborted) { + throw toError(failed.signal.reason, 'client api: Remote event result delivery failed') + } + if (signal.aborted) return + if (streamFailed) throw streamError + throw new Error('client api: forwarded Remote event stream ended unexpectedly') + } + + private async answer( + frame: RemoteEventInvocationFrame, + clientId: RemoteEventClientId, + signal: AbortSignal, + ): Promise { + const adapter = this.ownerCtx.typert.contexts.getClient('agent') + let target: Context | undefined + try { + target = adapter?.resolve(frame.agentId) + } catch (error) { + this.reportError(frame.event, error) + } + let outcome: RemoteEventReplyOutcome = { kind: 'next' } + if (target !== undefined) { + try { + outcome = await this.dispatchWaterfall(target, frame, signal) + } catch (error) { + if (signal.aborted) return + outcome = { kind: 'rejected', error: projectRemoteEventRejection(error) } + } + } + if (signal.aborted) return + const result: RemoteEventResult = { + clientId, + eventId: frame.eventId, + outcome: outcome.kind === 'result' && outcome.value === undefined + ? { kind: 'result' } + : outcome, + } + const response = await this.connection.rpc.call( + '/api', + REMOTE_EVENT_RESULT_ENDPOINT, + { args: result }, + signal, + ) + if (!response.ok) throw new Error(response.error.message) + } + + private async dispatchWaterfall( + target: Context, + frame: RemoteEventInvocationFrame, + signal: AbortSignal, + ): Promise { + const request = { + ...frame.request, + agent: target, + signal, + } + const value = await abortable( + Promise.resolve(privateEvents(target).waterfall( + target, + this.eventKey(frame.event), + request, + () => Promise.resolve(REMOTE_EVENT_NEXT), + )), + signal, + ) + if (value !== REMOTE_EVENT_NEXT && value !== undefined && !isRemoteJsonValue(value)) { + throw new TypeError('Remote event listener result is not lossless JSON data') + } + return value === REMOTE_EVENT_NEXT + ? { kind: 'next' } + : { kind: 'result', value } + } + + private eventKey(event: string): string { + return `${this.eventPrefix}${event}` + } + + private reportError(event: string, error: unknown): void { + console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error) + } +} + +/** Validate and return the Client identity from one generation's opening item. */ +function parseRemoteEventReady(value: unknown): RemoteEventClientId { + if (!isRemoteEventRecord(value) + || !hasExactRemoteEventKeys(value, ['type', 'clientId']) + || value.type !== 'ready' + || !isRemoteEventClientId(value.clientId)) { + throw new TypeError('client api: forwarded Remote event stream did not begin with ready') + } + return value.clientId +} + +/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */ +function parseRemoteEventFrame(value: unknown): Exclude { + if (!isRemoteEventRecord(value)) invalidRemoteEventFrame() + if (value.type === 'cancel' + && hasExactRemoteEventKeys(value, ['type', 'eventId']) + && isRemoteEventId(value.eventId)) { + return { type: 'cancel', eventId: value.eventId } + } + if (value.type === 'emit' + && hasExactRemoteEventKeys(value, ['type', 'event', 'args']) + && validRemoteEventName(value.event) + && Array.isArray(value.args) + && isRemoteJsonValue(value.args)) { + return { type: 'emit', event: value.event, args: value.args } + } + if (value.type === 'waterfall' + && hasExactRemoteEventKeys(value, ['type', 'event', 'eventId', 'agentId', 'request']) + && validRemoteEventName(value.event) + && isRemoteEventId(value.eventId) + && isRemoteEventAgentId(value.agentId) + && isRemoteEventRecord(value.request) + && !Object.hasOwn(value.request, 'agent') + && !Object.hasOwn(value.request, 'signal') + && isRemoteJsonValue(value.request)) { + return { + type: 'waterfall', + event: value.event, + eventId: value.eventId, + agentId: value.agentId, + request: value.request, + } + } + invalidRemoteEventFrame() +} + +function isRemoteEventRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function hasExactRemoteEventKeys(value: Record, keys: readonly string[]): boolean { + const ownKeys = Reflect.ownKeys(value) + return ownKeys.length === keys.length && keys.every(key => Object.hasOwn(value, key)) +} + +function validRemoteEventName(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function invalidRemoteEventFrame(): never { + throw new TypeError('client api: invalid forwarded Remote event frame') +} + +/** Race listener completion against its delivery lifetime. */ +async function abortable(value: T | PromiseLike, signal: AbortSignal): Promise { + signal.throwIfAborted() + let rejectAbort: ((reason: unknown) => void) | undefined + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { rejectAbort?.(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) + try { + return await Promise.race([Promise.resolve(value), aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +function privateEvents(ctx: Context): PrivateEventContext { + return ctx +} + +function toError(reason: unknown, message: string): Error { + return reason instanceof Error ? reason : new Error(message, { cause: reason }) +} diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts new file mode 100644 index 0000000000..799a371ef5 --- /dev/null +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -0,0 +1,210 @@ +/** Reconnecting lifecycle for one single-consumer Remote stream. */ + +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { RemoteStreamCarrierError } from './stream-client.ts' + +/** One item annotated with the physical Remote-stream generation that delivered it. */ +export interface RemoteStreamItem { + /** Monotone physical generation number within this logical stream. */ + readonly generation: number + /** Decoded item yielded by the generated Remote method. */ + readonly value: Item + /** Cancellation lifetime of the generation that delivered this item. */ + readonly signal: AbortSignal + /** Mark this generation's opening baseline or cursor as accepted. */ + accept(): void +} + +/** Domain-owned operations used by {@link RemoteStream}. */ +export interface RemoteStreamOptions { + /** Diagnostic owner name used for cancellation failures. */ + readonly name: string + /** Open one physical generation of the logical stream. */ + readonly open: (signal: AbortSignal) => AsyncIterable + /** Classify a normal generation end after or before its opening item was accepted. */ + readonly ended: (accepted: boolean) => Error + /** Observe a retryable carrier loss before the supervisor waits or reopens. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void +} + +/** + * Reopens one logical Remote stream across carrier generations. + * + * The Gateway owns physical retry timing, cancellation, and replacement. The + * domain consumer owns its opening item and every later item, and calls + * {@link RemoteStreamItem.accept} only after validating the opening + * baseline or cursor. + */ +export class RemoteStream implements AsyncIterable> { + private readonly lifetime = new AbortController() + private generationAbort: AbortController | undefined + private iterator: AsyncGenerator> | undefined + private closing: Promise | undefined + private revision = 0 + private taken = false + + /** + * @param connection - observable Host generation source used to pace retries. + * @param options - domain stream opener, end classification, and diagnostics. + */ + constructor( + private readonly connection: Pick, + private readonly options: RemoteStreamOptions, + ) {} + + /** Cancellation lifetime shared by the stream and sibling page requests. */ + get signal(): AbortSignal { + return this.lifetime.signal + } + + /** Interrupt the current generation and immediately request a replacement. */ + restart(): void { + if (this.lifetime.signal.aborted) return + this.revision++ + this.generationAbort?.abort(new Error(`${this.options.name} generation restarted`)) + } + + /** + * Permanently stop this stream and wait for its iterator to close. + * @returns when the active generation and consumer iterator are quiescent. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing + if (!this.lifetime.signal.aborted) { + const reason = new Error(`${this.options.name} disposed`) + this.lifetime.abort(reason) + this.generationAbort?.abort(reason) + } + const iterator = this.iterator + if (iterator === undefined) return Promise.resolve() + const closing = closeRemoteStreamIterator(iterator) + this.closing = closing + return closing + } + + /** @inheritdoc */ + [Symbol.asyncIterator](): AsyncIterator> { + if (this.taken) throw new Error(`${this.options.name} already has a consumer`) + this.taken = true + const iterator = this.read() + this.iterator = iterator + return iterator + } + + private async * read(): AsyncGenerator> { + let attempt = 0 + let generation = 0 + let observedRevision = this.revision + try { + while (!isAborted(this.lifetime.signal)) { + if (observedRevision !== this.revision) { + observedRevision = this.revision + attempt = 0 + } + const revision = this.revision + const generationAbort = new AbortController() + this.generationAbort = generationAbort + const signal = AbortSignal.any([this.lifetime.signal, generationAbort.signal]) + const generationId = ++generation + let accepted = false + try { + for await (const value of this.options.open(signal)) { + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) break + yield { + generation: generationId, + value, + signal, + accept: () => { + if (this.generationAbort !== generationAbort || revision !== this.revision) return + accepted = true + attempt = 0 + }, + } + } + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) continue + throw this.options.ended(accepted) + } catch (error) { + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) continue + if (!(error instanceof RemoteStreamCarrierError)) throw error + this.options.carrierFailed?.(error) + if (revision !== this.revision) continue + attempt++ + try { + await waitForRemoteStreamRetry(this.connection, error, attempt, signal) + } catch (retryError) { + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) continue + throw retryError + } + } finally { + this.generationAbort = undefined + if (!generationAbort.signal.aborted) { + generationAbort.abort(new Error(`${this.options.name} generation ended`)) + } + } + } + } finally { + if (!this.lifetime.signal.aborted) { + this.lifetime.abort(new Error(`${this.options.name} consumer closed`)) + } + this.generationAbort?.abort(this.lifetime.signal.reason) + this.generationAbort = undefined + } + } +} + +async function waitForRemoteStreamRetry( + connection: Pick, + error: RemoteStreamCarrierError, + attempt: number, + signal: AbortSignal, +): Promise { + signal.throwIfAborted() + if (connection.hostDescription.getSnapshot() !== undefined) { + if (attempt === 1) return + throw error + } + await new Promise((resolve, reject) => { + const subscription: { + dispose?: () => void + finished: boolean + } = { finished: false } + const finish = (failure?: Error): void => { + if (subscription.finished) return + subscription.finished = true + subscription.dispose?.() + signal.removeEventListener('abort', aborted) + if (failure === undefined) resolve() + else reject(failure) + } + const inspect = (): void => { + if (connection.hostDescription.getSnapshot() !== undefined) finish() + } + const aborted = (): void => { + finish(new Error('Remote stream retry aborted', { cause: signal.reason })) + } + const dispose = connection.hostDescription.subscribe(inspect) + subscription.dispose = dispose + if (subscription.finished) dispose() + signal.addEventListener('abort', aborted, { once: true }) + if (signal.aborted) aborted() + else inspect() + }) +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +async function closeRemoteStreamIterator( + iterator: AsyncIterator>, +): Promise { + try { + await iterator.return?.() + } catch { + // The disposed logical stream has no remaining consumer for cancellation failures. + } +} diff --git a/packages/api/gateway/src/client/snapshot-stream.ts b/packages/api/gateway/src/client/snapshot-stream.ts new file mode 100644 index 0000000000..daa9660df9 --- /dev/null +++ b/packages/api/gateway/src/client/snapshot-stream.ts @@ -0,0 +1,88 @@ +/** Baseline-and-delta protocol layered over a reconnecting Remote stream. */ + +import type { RemoteStream } from './remote-stream.ts' + +/** Domain operations for one snapshot stream. */ +export interface RemoteSnapshotStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string + /** Distinguish the opening snapshot from later deltas. */ + readonly isSnapshot: (value: Snapshot | Delta) => value is Snapshot + /** Atomically replace the domain model from a complete snapshot. */ + readonly replace: (snapshot: Snapshot) => void + /** Apply one incremental update after the generation snapshot. */ + readonly update: (delta: Delta) => void + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void +} + +/** + * Consumes generations that each contain exactly one opening snapshot followed by deltas. + * + * The previous domain snapshot remains published while the underlying stream retries. A + * replacement becomes accepted only after the domain owner applies it successfully. + */ +export class RemoteSnapshotStream { + private started = false + private disposed = false + private done: Promise | undefined + + /** + * @param stream - reconnecting physical-generation stream. + * @param options - frame discriminator and domain state destinations. + */ + constructor( + private readonly stream: RemoteStream, + private readonly options: RemoteSnapshotStreamOptions, + ) {} + + /** Start the single consumer; repeated calls are inert. */ + start(): void { + if (this.started) return + this.started = true + this.done = this.consume() + } + + /** Replace the active physical generation without discarding the published snapshot. */ + restart(): void { + this.stream.restart() + } + + /** + * Permanently stop the stream and wait for its consumer to become quiescent. + * @returns when no generation or callback can still run. + */ + async dispose(): Promise { + this.disposed = true + await this.stream.dispose() + await this.done + } + + private async consume(): Promise { + let generation = 0 + let snapshotSeen = false + try { + for await (const item of this.stream) { + if (item.generation !== generation) { + generation = item.generation + snapshotSeen = false + } + if (this.options.isSnapshot(item.value)) { + if (snapshotSeen) { + throw new Error(`${this.options.name} emitted more than one opening snapshot`) + } + this.options.replace(item.value) + snapshotSeen = true + item.accept() + continue + } + if (!snapshotSeen) { + throw new Error(`${this.options.name} emitted an update before its opening snapshot`) + } + this.options.update(item.value) + } + } catch (error) { + if (!this.disposed) this.options.failed(error) + } + } +} diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts new file mode 100644 index 0000000000..0dd56cc72d --- /dev/null +++ b/packages/api/gateway/src/client/stream-client.ts @@ -0,0 +1,348 @@ +/** Browser owner for the Gateway multiplexed Remote stream socket. */ + +import { + parseRemoteStreamServerMessage, + REMOTE_STREAM_MUX_PATH, + type RemoteStreamClientMessage, + type RemoteStreamServerMessage, +} from '../stream-protocol.ts' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' + +const INTERNAL_BASE = 'http://dsh.internal' +const RECONNECT_BASE_MS = 500 +const RECONNECT_FACTOR = 2 +const RECONNECT_MAX_MS = 10_000 + +/** One Host-reported Remote stream failure. */ +export class RemoteStreamError extends Error { + /** Stable carrier or Gateway error category. */ + readonly code: string + /** Host-provided structured failure context. */ + readonly details: object + + /** + * @param code - stable Gateway or business error category. + * @param message - Host-provided failure description. + * @param details - Host-provided structured failure context. + */ + constructor(code: string, message: string, details: object) { + super(message) + this.name = 'RemoteStreamError' + this.code = code + this.details = details + } +} + +/** Physical Remote stream socket failure that may be retried by a domain transport. */ +export class RemoteStreamCarrierError extends Error { + /** + * @param message - physical carrier failure description. + * @param options - optional causal error. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'RemoteStreamCarrierError' + } +} + +interface SocketWaiter { + resolve(socket: WebSocket): void + reject(error: unknown): void +} + +/** Keep one physical WebSocket and share it among independently cancellable Remote streams. */ +export class RemoteStreamMuxClient { + private socket: WebSocket | undefined + private cancelCandidate: ((error: Error) => void) | undefined + private keepAlive: Promise | undefined + private keepAliveAbort: AbortController | undefined + private readonly streams = new Map() + private readonly waiters = new Set() + private running = false + private disposed = false + + /** Start the persistent physical connection; repeated calls are inert. */ + start(): void { + if (this.running || this.disposed) return + this.running = true + this.maintain() + } + + /** + * Open one logical stream on the persistent physical connection. + * @param endpoint - Typert Remote stream endpoint. + * @param payload - endpoint request encoded on the wire. + * @param signal - cancellation for this logical stream. + * @returns Host items until completion, cancellation, or failure. + */ + async *open( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ): AsyncGenerator { + this.start() + signal.throwIfAborted() + const streamId = randomUUID() + const inbox = new StreamInbox() + let carrier: WebSocket | undefined + let opened = false + let terminal = false + const abort = (): void => { inbox.fail(signal.reason) } + signal.addEventListener('abort', abort, { once: true }) + try { + const socket = await this.waitForSocket(signal) + signal.throwIfAborted() + carrier = socket + this.streams.set(streamId, inbox) + this.send(socket, { type: 'open', streamId, endpoint, payload }) + opened = true + while (true) { + const frame = await inbox.next() + signal.throwIfAborted() + if (frame.type === 'item') { + yield frame.value + continue + } + terminal = true + if (frame.type === 'error') { + throw new RemoteStreamError(frame.error.code, frame.error.message, frame.error.details) + } + return + } + } finally { + signal.removeEventListener('abort', abort) + this.streams.delete(streamId) + if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { + this.send(carrier, { type: 'cancel', streamId }) + } + } + } + + /** + * Permanently stop reconnecting, close the physical socket, and fail every active logical stream. + * @returns once the background connection loop has stopped. + */ + async close(): Promise { + if (!this.disposed) { + this.disposed = true + this.running = false + const error = new Error('api gateway: Remote stream client disposed') + this.keepAliveAbort?.abort(error) + this.keepAliveAbort = undefined + this.failAll(error) + for (const waiter of [...this.waiters]) waiter.reject(error) + this.cancelCandidate?.(error) + const socket = this.socket + this.socket = undefined + socket?.close(1000, 'disposed') + } + await this.keepAlive + } + + private connect(): Promise { + const socket = new WebSocket(remoteStreamUrl()) + const connecting = new Promise((resolve, reject) => { + let settled = false + const rejectCandidate = (error: Error): void => { + settled = true + socket.removeEventListener('open', opened) + socket.removeEventListener('error', failed) + socket.removeEventListener('message', received) + socket.removeEventListener('close', closed) + this.cancelCandidate = undefined + socket.close() + reject(error) + } + const opened = (): void => { + settled = true + this.cancelCandidate = undefined + this.socket = socket + for (const waiter of [...this.waiters]) waiter.resolve(socket) + resolve(socket) + } + const failed = (): void => { + if (!settled) { + rejectCandidate(new RemoteStreamCarrierError( + 'api gateway: Remote stream WebSocket failed to open', + )) + return + } + const error = new RemoteStreamCarrierError('api gateway: Remote stream WebSocket failed') + this.lost(socket, error) + socket.close() + } + const closed = (): void => { + if (!settled) { + rejectCandidate(new RemoteStreamCarrierError( + 'api gateway: Remote stream WebSocket closed before opening', + )) + return + } + this.lost(socket) + } + const received = (event: MessageEvent): void => { this.receive(socket, event.data) } + this.cancelCandidate = rejectCandidate + socket.addEventListener('open', opened, { once: true }) + socket.addEventListener('error', failed, { once: true }) + socket.addEventListener('message', received) + socket.addEventListener('close', closed, { once: true }) + }) + return connecting + } + + private waitForSocket(signal: AbortSignal): Promise { + signal.throwIfAborted() + if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket) + if (this.disposed) return Promise.reject(new Error('api gateway: Remote stream client disposed')) + this.start() + return new Promise((resolve, reject) => { + const aborted = (): void => { waiter.reject(signal.reason) } + const cleanup = (): void => { + this.waiters.delete(waiter) + signal.removeEventListener('abort', aborted) + } + const waiter: SocketWaiter = { + resolve: (socket) => { + cleanup() + resolve(socket) + }, + reject: (error) => { + cleanup() + // AbortSignal.reason belongs to the caller and may intentionally be a non-Error sentinel. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + reject(error) + }, + } + this.waiters.add(waiter) + signal.addEventListener('abort', aborted, { once: true }) + }) + } + + private receive(socket: WebSocket, data: unknown): void { + if (socket !== this.socket) return + try { + if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages') + const frame = parseRemoteStreamServerMessage(data) + this.streams.get(frame.streamId)?.push(frame) + } catch (error) { + const failure = new RemoteStreamCarrierError('api gateway: invalid Remote stream frame', { cause: error }) + this.failAll(failure) + this.lost(socket, failure) + socket.close(4002, 'invalid Remote stream frame') + } + } + + private lost( + socket: WebSocket, + error: RemoteStreamCarrierError = new RemoteStreamCarrierError( + 'api gateway: Remote stream WebSocket closed', + ), + ): void { + if (this.socket !== socket) return + this.socket = undefined + this.failAll(error) + this.maintain(error) + } + + private maintain(previousFailure?: Error): void { + if (!this.running) return + if (this.keepAlive !== undefined) { + void this.keepAlive.then(() => { this.maintain(previousFailure) }) + return + } + const abort = new AbortController() + this.keepAliveAbort = abort + const task = this.reconnect(abort.signal, previousFailure) + this.keepAlive = task + void task.then(() => { + this.keepAlive = undefined + this.keepAliveAbort = undefined + }) + } + + private async reconnect(signal: AbortSignal, previousFailure?: Error): Promise { + let attempt = 0 + let failure = previousFailure + while (this.isRunning(signal) && this.socket?.readyState !== WebSocket.OPEN) { + if (failure !== undefined) { + attempt += 1 + console.warn(`[api-gateway] Remote stream connection unavailable, retry #${String(attempt)}`, failure) + await sleep(backoffDelay(attempt), signal) + if (!this.isRunning(signal)) return + } + try { + await this.connect() + return + } catch (error) { + if (!this.isRunning(signal)) return + failure = error as Error + } + } + } + + private isRunning(signal: AbortSignal): boolean { + return this.running && !signal.aborted + } + + private failAll(error: unknown): void { + for (const stream of this.streams.values()) stream.fail(error) + } + + private send(socket: WebSocket, message: RemoteStreamClientMessage): void { + socket.send(JSON.stringify(message)) + } +} + +function backoffDelay(attempt: number): number { + const cap = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * RECONNECT_FACTOR ** Math.max(0, attempt - 1)) + return cap / 2 + Math.random() * (cap / 2) +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + signal.addEventListener('abort', done, { once: true }) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + }) +} + +class StreamInbox { + private readonly frames: RemoteStreamServerMessage[] = [] + private wake: (() => void) | undefined + private failure: Error | undefined + + push(frame: RemoteStreamServerMessage): void { + if (this.failure !== undefined) return + this.frames.push(frame) + this.wake?.() + this.wake = undefined + } + + fail(error: unknown): void { + if (this.failure !== undefined) return + this.failure = error instanceof Error ? error : new Error(String(error), { cause: error }) + this.frames.length = 0 + this.wake?.() + this.wake = undefined + } + + async next(): Promise { + while (this.frames.length === 0) { + if (this.failure !== undefined) throw this.failure + await new Promise((resolve) => { this.wake = resolve }) + } + return this.frames.shift() as RemoteStreamServerMessage + } +} + +function remoteStreamUrl(): string { + const location = (globalThis as { location?: { origin?: string } }).location + const base = location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE + const url = new URL(REMOTE_STREAM_MUX_PATH, base) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return url.href +} diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 9edb09d9b5..208090851e 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1,14 +1,18 @@ /** * Live Typert Remote dispatch over Cordis Services and registered providers. - * Transport, request correlation, and response envelopes belong to Connection. + * Unary transport and response envelopes belong to Connection; live Remote + * streams use the Gateway-owned WebSocket mux. * @module @deepseek-ai/dsh-api-gateway */ +import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { remoteMethods, TypertLookupFailure, + TypertRemoteFailure, type InvocationDescriptor, type InvocationParameterDescriptor, type TypertCodec, @@ -18,12 +22,47 @@ import type { InvokeRemoteRequest, TypertGateway, TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, } from './types.ts' +import { + RemoteStreamMuxServer, + rejectRemoteStreamUpgrade, +} from './stream-server.ts' +import { + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_READY, + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_STREAM_MUX_PATH, + isRemoteEventAgentId, + isRemoteJsonValue, + parseRemoteEventResult, + projectRemoteEventRequest, + restoreRemoteEventRejection, + type RemoteEventCancellationFrame, + type RemoteEventClientId, + type RemoteEventEmitFrame, + type RemoteEventId, + type RemoteEventInvocationFrame, + type RemoteEventReadyFrame, + type RemoteStreamFailure, +} from './stream-protocol.ts' export type { InvokeRemoteRequest, TypertGateway, TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventContext, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, } from './types.ts' interface GatewayErrorOptions { @@ -36,6 +75,34 @@ interface ResolvedBinding { readonly original: object } +interface PreparedInvocation { + readonly endpoint: string + readonly descriptor: InvocationDescriptor + readonly receiver: object + readonly args: readonly unknown[] + readonly method: (...args: never[]) => unknown +} + +interface RegisteredRemoteEventSource { + readonly lifetime: AbortController + readonly done: Promise +} + +interface RemoteEventClient { + readonly id: RemoteEventClientId + readonly queue: RemoteEventQueue + readonly deliveries: Map +} + +interface PendingRemoteEvent { + readonly id: RemoteEventId + readonly source: TypertRemoteEventInvocation + readonly frame: RemoteEventInvocationFrame + readonly deliveries: Set + releaseContext: () => void + releaseSignal: () => void +} + type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal @@ -90,7 +157,16 @@ class RemoteInvocationCancelled extends Error { export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + /** Carrier adapter shared by the WebSocket mux and local Host transports. */ + readonly wireStream: TypertGatewayWireStream = { + open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + failure: error => rpcError(error), + } + private srcClaims: ReadonlySet | undefined + private remoteEvents: RegisteredRemoteEventSource | undefined + private readonly remoteEventClients = new Map() + private readonly pendingRemoteEvents = new Map() /** * Register the Gateway against the active Typert registry. @@ -109,9 +185,63 @@ export class TypertGatewayService extends Service implements TypertGateway { { authority: 'trusted-host' }, ) }) + ctx.inject(['connection', 'webServer'], (webCtx) => { + const mux = new RemoteStreamMuxServer( + (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + this.wireStream.failure, + ) + webCtx.effect(() => { + const route: WebUpgradeRoute = { + path: REMOTE_STREAM_MUX_PATH, + handler: (req, socket, head) => { + if (!webCtx.connection.isTrustedRequest(req, 'trusted-host')) { + rejectRemoteStreamUpgrade(socket) + return + } + mux.handleUpgrade(req, socket, head) + }, + } + const unregister = webCtx.webServer.registerUpgrade(route) + return async () => { + unregister() + await mux.close() + } + }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`) + }) + } + + /** + * Register the sole application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this source and cancelling its active streams. + */ + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise { + if (this.remoteEvents !== undefined) { + throw new Error('typert gateway: forwarded Remote event source is already registered') + } + const lifetime = new AbortController() + const stream = source(lifetime.signal) + const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => { + if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return + this.closeRemoteEvents(error) + this.remoteEvents = undefined + lifetime.abort(error) + }) + const registration: RegisteredRemoteEventSource = { lifetime, done } + this.remoteEvents = registration + return async () => { + if (this.remoteEvents === registration) { + this.remoteEvents = undefined + const error = new Error('typert gateway: forwarded Remote event source was removed') + registration.lifetime.abort(error) + this.closeRemoteEvents(error) + } + await registration.done + } } private claimsEndpoint(endpoint: string): boolean { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true @@ -139,10 +269,314 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. - * @returns the validated business result. + * @returns the business result without output decoding. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { + const prepared = await this.prepareInvocation(request) + if (prepared.descriptor.mode === 'stream') { + throw new TypertGatewayError( + 'signature-invalid', + prepared.endpoint, + 'stream Remote methods must be opened through the stream carrier', + ) + } + + try { + return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown + } catch (error) { + if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + throw error + } + } + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns a cancellation-aware iterable over the business results. + */ + async stream(request: InvokeRemoteRequest): Promise> { + const prepared = await this.prepareInvocation(request) + if (prepared.descriptor.mode !== 'stream') { + throw new TypertGatewayError( + 'signature-invalid', + prepared.endpoint, + 'unary Remote methods cannot be opened through the stream carrier', + ) + } + let source: unknown + try { + source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown + } catch (error) { + if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + throw error + } + if (!isIterable(source)) { + throw new TypertGatewayError( + 'result-invalid', + prepared.endpoint, + 'stream Remote method did not return Iterable or AsyncIterable', + { field: 'result' }, + ) + } + return cancellableStream( + source, + prepared.endpoint, + request.signal ?? NEVER_ABORTED_SIGNAL, + ) + } + + private async dispatchRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ): Promise { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { + try { + const result = parseRemoteEventResultPayload(payload) + const client = this.remoteEventClients.get(result.clientId) + if (client === undefined) { + throw new Error('typert gateway: Remote event result identifies no active event stream') + } + this.receiveRemoteEventResult(client, result) + return { ok: true, value: undefined } + } catch (error) { + return rpcFailure(error) + } + } + return this.invokeRpc(endpoint, payload, signal) + } + + private async openWireStream( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ): Promise> { + if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { + return this.openRemoteEvents(payload, signal) + } + return this.stream(remoteRequest(endpoint, payload, signal)) + } + + private async *openRemoteEvents( + payload: unknown, + signal: AbortSignal, + ): AsyncGenerator< + RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame + | RemoteEventReadyFrame + > { + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args') + || !isObject(payload.args) + || !isPlainObject(payload.args) + || Reflect.ownKeys(payload.args).length !== 0) { + throw new TypertGatewayError( + 'arguments-invalid', + REMOTE_EVENT_STREAM_ENDPOINT, + 'forwarded Remote event stream requires an empty args object', + ) + } + const registration = this.remoteEvents + if (registration === undefined) { + throw new TypertGatewayError( + 'service-unavailable', + REMOTE_EVENT_STREAM_ENDPOINT, + 'forwarded Remote event source is unavailable', + ) + } + const lifetime = AbortSignal.any([signal, registration.lifetime.signal]) + let clientId = randomUUID() as RemoteEventClientId + while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId + const client: RemoteEventClient = { + id: clientId, + queue: new RemoteEventQueue(), + deliveries: new Map(), + } + this.remoteEventClients.set(clientId, client) + for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + try { + yield { ...REMOTE_EVENT_STREAM_READY, clientId } + yield* client.queue.iterate(lifetime) + } finally { + this.removeRemoteEventClient(client) + } + } + + private async consumeRemoteEvents( + source: AsyncIterable, + signal: AbortSignal, + ): Promise { + for await (const dispatch of source) { + if (signal.aborted) { + if ('context' in dispatch) dispatch.reject(signal.reason) + return + } + if ('context' in dispatch) this.startRemoteEvent(dispatch) + else this.broadcastRemoteEvent(dispatch) + } + if (!signal.aborted) { + throw new Error('typert gateway: forwarded Remote event source ended unexpectedly') + } + } + + private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void { + assertRemoteEventFrame(frame) + const wire: RemoteEventEmitFrame = { + type: 'emit', + event: frame.event, + args: frame.args, + } + for (const client of this.remoteEventClients.values()) client.queue.push(wire) + } + + private startRemoteEvent(source: TypertRemoteEventInvocation): void { + try { + assertRemoteEventName(source) + const context = this.ctx.typert.contexts.identifyHost(source.context.value) + if (context === undefined) { + source.resolve({ kind: 'next' }) + return + } + if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) { + throw new TypeError( + 'typert gateway: scoped Remote events require a non-empty Agent identity', + ) + } + const projected = projectRemoteEventRequest(source.request, source.context.subject) + let id = randomUUID() as RemoteEventId + while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId + let releaseContext: () => void + try { + const dispose = source.context.value.effect( + () => () => { + this.cancelRemoteEvent( + pending, + new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`), + ) + }, + `api-gateway: Remote event ${JSON.stringify(source.event)}`, + ) + releaseContext = () => { void dispose() } + } catch { + source.resolve({ kind: 'next' }) + return + } + const signals = new Set(projected.signal === undefined ? [] : [projected.signal]) + const abort = (): void => { + const reason = [...signals].find(signal => signal.aborted)?.reason as unknown + this.cancelRemoteEvent(pending, reason instanceof Error + ? reason + : new Error('typert gateway: Remote event was cancelled', { cause: reason })) + } + const pending: PendingRemoteEvent = { + id, + source, + frame: { + type: 'waterfall', + event: source.event, + eventId: id, + agentId: context.identity, + request: projected.request, + }, + deliveries: new Set(), + releaseContext, + releaseSignal: () => { + for (const signal of signals) signal.removeEventListener('abort', abort) + }, + } + this.pendingRemoteEvents.set(id, pending) + for (const signal of signals) signal.addEventListener('abort', abort, { once: true }) + if ([...signals].some(signal => signal.aborted)) abort() + else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client) + } catch (error) { + source.reject(error) + } + } + + private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.add(client) + client.deliveries.set(pending.id, pending) + client.queue.push(pending.frame) + } + + private receiveRemoteEventResult( + client: RemoteEventClient, + result: ReturnType, + ): void { + const pending = this.pendingRemoteEvents.get(result.eventId) + // Settlement and Client replacement may race the result request. Results + // from a completed event or a superseded delivery are idempotent no-ops. + if (pending === undefined || !pending.deliveries.has(client)) return + this.removeRemoteEventDelivery(pending, client) + if (result.outcome.kind === 'result') { + this.settleRemoteEvent(pending, { + kind: 'result', + value: result.outcome.value, + }) + } else if (result.outcome.kind === 'rejected') { + this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error)) + } else if (pending.deliveries.size === 0) { + this.settleRemoteEvent(pending, { kind: 'next' }) + } + } + + private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.delete(client) + client.deliveries.delete(pending.id) + } + + private removeRemoteEventClient(client: RemoteEventClient): void { + this.remoteEventClients.delete(client.id) + for (const pending of [...client.deliveries.values()]) this.removeRemoteEventDelivery(pending, client) + client.queue.end() + } + + private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void { + this.finishRemoteEvent(pending) + pending.source.resolve(outcome) + } + + private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void { + if (this.pendingRemoteEvents.get(pending.id) !== pending) return + this.finishRemoteEvent(pending) + pending.source.reject(reason) + } + + private finishRemoteEvent(pending: PendingRemoteEvent): void { + this.pendingRemoteEvents.delete(pending.id) + pending.releaseSignal() + pending.releaseContext() + const clients = new Set(pending.deliveries) + for (const client of clients) this.removeRemoteEventDelivery(pending, client) + const cancellation: RemoteEventCancellationFrame = { + type: 'cancel', + eventId: pending.id, + } + for (const client of clients) client.queue.push(cancellation) + } + + private closeRemoteEvents(reason: unknown): void { + for (const pending of [...this.pendingRemoteEvents.values()]) { + this.cancelRemoteEvent(pending, reason) + } + for (const client of [...this.remoteEventClients.values()]) client.queue.end() + } + + private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { + try { + const value = await this.invoke(remoteRequest(endpoint, payload, signal)) + // A void or explicitly absent business result carries no `value` field; + // JSON has no `undefined`, and the envelope's optional slot is the one + // representation of absence that both args and results already use. + return { ok: true, value } + } catch (error) { + return rpcFailure(error) + } + } + + private async prepareInvocation(request: InvokeRemoteRequest): Promise { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) @@ -168,57 +602,7 @@ export class TypertGatewayService extends Service implements TypertGateway { `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, ) } - - let result: unknown - try { - result = await Reflect.apply(method, receiver, args) as unknown - } catch (error) { - if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error) - throw error - } - // A weak descriptor declares no return type, so nothing returned is a void - // result and rides the wire as an absent value field. A strict descriptor - // keeps its schema: there, undefined has to be a declared result. - if (result === undefined && descriptor.result.mode !== 'strict') return result - return decode(descriptor.result, result, 'result-invalid', endpoint, 'result') - } - - private async dispatchRpc( - endpoint: string, - payload: unknown, - signal: AbortSignal, - ): Promise { - return this.invokeRpc(endpoint, payload, signal) - } - - private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { - try { - const segments = endpoint.split('/') - if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { - throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) - } - const [namespace, method] = segments as [string, string] - if (!isObject(payload) - || !isPlainObject(payload) - || Reflect.ownKeys(payload).length !== 1 - || !Object.hasOwn(payload, 'args') - || !isObject(payload.args) - || !isPlainObject(payload.args)) { - throw new Error('Remote payload must contain exactly one plain-object args field') - } - const value = await this.invoke({ - namespace, - method, - args: payload.args, - signal, - }) - // A void or explicitly absent business result carries no `value` field; - // JSON has no `undefined`, and the envelope's optional slot is the one - // representation of absence that both args and results already use. - return { ok: true, value } - } catch (error) { - return rpcFailure(error) - } + return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown } } private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { @@ -349,6 +733,7 @@ export class TypertGatewayService extends Service implements TypertGateway { namespace: binding.namespace, method, ...(marker.method === method ? {} : { implementation: marker.method }), + ...(marker.mode === undefined ? {} : { mode: marker.mode }), invocation: receiver, parameters, ...(cancellation === undefined ? {} : { cancellation }), @@ -380,7 +765,7 @@ export class TypertGatewayService extends Service implements TypertGateway { { field: invocation.wire }, ) } - const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire) + const identity = decode(invocation.codec, args[invocation.wire], endpoint, invocation.wire) let context: Context | undefined try { context = await provider.resolve(identity) @@ -414,7 +799,7 @@ export class TypertGatewayService extends Service implements TypertGateway { // still fails decode. Lookup ids are never omissible, so absence here only // ever belongs to a json parameter. if (!Object.hasOwn(args, parameter.wire)) return undefined - const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) + const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire) if (parameter.source === 'json') return value const key = parameter.lookup /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ @@ -468,6 +853,120 @@ export class TypertGatewayService extends Service implements TypertGateway { } } +type RemoteEventWireFrame = + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame + +/** Pull-driven queue owned by one connected Client event generation. */ +class RemoteEventQueue { + private readonly frames: RemoteEventWireFrame[] = [] + private waiter: (() => void) | undefined + private closed = false + + push(frame: RemoteEventWireFrame): void { + if (this.closed) return + this.frames.push(frame) + this.waiter?.() + } + + end(): void { + if (this.closed) return + this.closed = true + this.waiter?.() + } + + async *iterate(signal: AbortSignal): AsyncGenerator { + const abort = (): void => { this.end() } + signal.addEventListener('abort', abort, { once: true }) + try { + while (true) { + while (this.frames.length > 0) yield this.frames.shift() as RemoteEventWireFrame + if (this.closed || signal.aborted) return + await new Promise((resolve) => { this.waiter = resolve }) + this.waiter = undefined + } + } finally { + signal.removeEventListener('abort', abort) + } + } +} + +function assertRemoteEventFrame(frame: TypertRemoteEventFrame): void { + assertRemoteEventName(frame) + if (!Array.isArray(frame.args) || !isRemoteJsonValue(frame.args)) { + throw new TypeError(`typert gateway: Remote event ${JSON.stringify(frame.event)} arguments are not lossless JSON data`) + } +} + +function assertRemoteEventName(frame: { readonly event: unknown }): void { + if (typeof frame.event !== 'string' || frame.event.length === 0) { + throw new TypeError('typert gateway: Remote event name must be a nonempty string') + } +} + +function parseRemoteEventResultPayload(payload: unknown): ReturnType { + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args')) { + throw new Error('typert gateway: Remote event result requires exactly one plain-object args field') + } + return parseRemoteEventResult(payload.args) +} + +function remoteRequest(endpoint: string, payload: unknown, signal: AbortSignal): InvokeRemoteRequest { + const segments = endpoint.split('/') + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { + throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) + } + const [namespace, method] = segments as [string, string] + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args') + || !isObject(payload.args) + || !isPlainObject(payload.args)) { + throw new Error('Remote payload must contain exactly one plain-object args field') + } + return { namespace, method, args: payload.args, signal } +} + +function isIterable(value: unknown): value is Iterable | AsyncIterable { + return isObject(value) + && (typeof Reflect.get(value, Symbol.iterator) === 'function' + || typeof Reflect.get(value, Symbol.asyncIterator) === 'function') +} + +async function *cancellableStream( + source: Iterable | AsyncIterable, + endpoint: string, + signal: AbortSignal, +): AsyncGenerator { + const asyncFactory = Reflect.get(source, Symbol.asyncIterator) as unknown + const syncFactory = Reflect.get(source, Symbol.iterator) as unknown + const iterator = typeof asyncFactory === 'function' + ? Reflect.apply(asyncFactory, source, []) as AsyncIterator + : Reflect.apply(syncFactory as (...args: never[]) => Iterator, source, []) + let rejectAbort: ((error: unknown) => void) | undefined + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { + rejectAbort?.(new RemoteInvocationCancelled(endpoint, signal.reason)) + } + signal.addEventListener('abort', onAbort, { once: true }) + try { + if (signal.aborted) throw new RemoteInvocationCancelled(endpoint, signal.reason) + while (true) { + const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) + if (next.done === true) return + yield next.value + } + } finally { + signal.removeEventListener('abort', onAbort) + await iterator.return?.() + } +} + function rpcFailure(error: unknown): ConnectionRpcResult { if (error instanceof RemoteInvocationCancelled) { return { @@ -478,6 +977,9 @@ function rpcFailure(error: unknown): ConnectionRpcResult { if (error instanceof TypertLookupFailure) { return { ok: false, error: error.failure as ConnectionRpcError } } + if (error instanceof TypertRemoteFailure) { + return { ok: false, error: error.failure } + } return { ok: false, error: { @@ -488,6 +990,10 @@ function rpcFailure(error: unknown): ConnectionRpcResult { } } +function rpcError(error: unknown): ConnectionRpcError & RemoteStreamFailure { + return (rpcFailure(error) as Extract).error +} + function endpointOf(namespace: string, method: string): string { return `${namespace}/${method}` } @@ -614,24 +1120,22 @@ function assertExactArguments( function decode( codec: TypertCodec, value: unknown, - code: 'input-invalid' | 'result-invalid', endpoint: string, field: string, ): unknown { try { if (codec.mode === 'strict') { value = codec.schema.parse(value) + /* v8 ignore next -- generated optional-input codecs are the only strict codecs that return undefined. */ if (value === undefined) return value } assertJsonValue(value, new Set()) return value } catch (cause) { throw new TypertGatewayError( - code, + 'input-invalid', endpoint, - code === 'input-invalid' - ? `wire field ${JSON.stringify(field)} failed boundary validation` - : 'business result failed boundary validation', + `wire field ${JSON.stringify(field)} failed boundary validation`, { cause, field }, ) } diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts new file mode 100644 index 0000000000..2c6e8ebf70 --- /dev/null +++ b/packages/api/gateway/src/stream-protocol.ts @@ -0,0 +1,399 @@ +/** Wire messages for Gateway-owned Remote streams and event-result RPCs. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Exact WebSocket route carrying every Typert Remote stream. */ +export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux' + +/** Gateway-internal logical stream carrying application-selected Cordis events. */ +export const REMOTE_EVENT_STREAM_ENDPOINT = '$events' + +/** Gateway-internal unary endpoint returning one Client Remote Event outcome. */ +export const REMOTE_EVENT_RESULT_ENDPOINT = '$events/result' + +/** Empty standard Remote payload used to open the forwarded-event stream. */ +export const REMOTE_EVENT_STREAM_PAYLOAD = { args: {} } as const + +/** Discriminator for the first item proving the Host event source is ready. */ +export const REMOTE_EVENT_STREAM_READY = { type: 'ready' } as const + +/** Opaque identity for one active Client Remote Event generation. */ +export type RemoteEventClientId = Branded<'RemoteEventClientId'> + +/** Opaque correlation id for one pending Host-to-Client Remote Event. */ +export type RemoteEventId = Branded<'RemoteEventId'> + +/** Opening item that binds later HTTP results to this active event stream. */ +export interface RemoteEventReadyFrame { + readonly type: 'ready' + readonly clientId: RemoteEventClientId +} + +/** Opaque Agent identity carried by one scoped Remote Event. */ +export type RemoteEventAgentId = Branded<'RemoteEventAgentId'> + +/** One Host notification delivered to a Client generation. */ +export interface RemoteEventEmitFrame { + readonly type: 'emit' + readonly event: string + readonly args: readonly unknown[] +} + +/** One pending Agent-scoped waterfall delivered to a Client generation. */ +export interface RemoteEventInvocationFrame { + readonly type: 'waterfall' + readonly event: string + readonly eventId: RemoteEventId + readonly agentId: RemoteEventAgentId + readonly request: Readonly> +} + +/** Cancellation of a pending waterfall previously delivered under the same id. */ +export interface RemoteEventCancellationFrame { + readonly type: 'cancel' + readonly eventId: RemoteEventId +} + +/** Every item carried by the Gateway-internal forwarded-event stream. */ +export type RemoteEventDownlinkFrame = + | RemoteEventReadyFrame + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame + +/** JSON request fields plus the Host cancellation lifetime removed for transport. */ +export interface ProjectedRemoteEventRequest { + readonly request: Readonly> + readonly signal?: AbortSignal +} + +/** Error fields retained when a Client listener rejects a Host waterfall. */ +export interface RemoteEventRejection { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown +} + +/** Client response to one scoped Remote Event delivery. */ +export interface RemoteEventResult { + readonly clientId: RemoteEventClientId + readonly eventId: RemoteEventId + readonly outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { readonly kind: 'rejected'; readonly error: RemoteEventRejection } +} + +/** + * Parse one result sent through the Client's `$events/result` HTTP RPC. + * @param value - untrusted result payload. + * @returns validated event correlation and outcome fields. + */ +export function parseRemoteEventResult(value: unknown): RemoteEventResult { + if (!isRecord(value) + || !exactKeys(value, ['clientId', 'eventId', 'outcome']) + || !isRemoteEventClientId(value.clientId) + || !isRemoteEventId(value.eventId) + || !isRecord(value.outcome)) { + throw new Error('api gateway: invalid Remote event result') + } + const outcome = value.outcome + if (outcome.kind === 'next' && exactKeys(outcome, ['kind'])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: 'next' }, + } + } + if (outcome.kind === 'result' + && (exactKeys(outcome, ['kind']) || exactKeys(outcome, ['kind', 'value'])) + && (!Object.hasOwn(outcome, 'value') || isRemoteJsonValue(outcome.value))) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: Object.hasOwn(outcome, 'value') + ? { kind: 'result', value: outcome.value } + : { kind: 'result' }, + } + } + if (outcome.kind === 'rejected' + && exactKeys(outcome, ['kind', 'error'])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: 'rejected', error: parseRemoteEventRejection(outcome.error) }, + } + } + throw new Error('api gateway: invalid Remote event result') +} + +/** + * Remove the direct Agent and cancellation fields from one waterfall request. + * @param value - request object before the waterfall's `next` callback. + * @param subject - Agent used by the Cordis scope carrier. + * @returns JSON-safe request fields and the optional Host cancellation signal. + */ +export function projectRemoteEventRequest( + value: unknown, + subject: object, +): ProjectedRemoteEventRequest { + if (!isPlainRecord(value) || !Object.hasOwn(value, 'agent') || value.agent !== subject) { + throw new TypeError('api gateway: Remote event request must carry its scoped Agent directly') + } + const signal = value.signal + if (signal !== undefined && !(signal instanceof AbortSignal)) { + throw new TypeError('api gateway: Remote event request signal must be an AbortSignal') + } + const request: Record = Object.create(null) as Record + for (const key of Reflect.ownKeys(value)) { + if (key === 'agent' || key === 'signal') continue + const descriptor = typeof key === 'string' ? Object.getOwnPropertyDescriptor(value, key) : undefined + if (typeof key !== 'string' || descriptor?.enumerable !== true) { + throw new TypeError('api gateway: Remote event request has a non-JSON property') + } + request[key] = Reflect.get(value, key) + } + if (!isRemoteJsonValue(request)) { + throw new TypeError('api gateway: Remote event request is not lossless JSON data') + } + return { + request, + ...(signal === undefined ? {} : { signal }), + } +} + +/** + * Project an arbitrary rejection to stable, JSON-safe error fields. + * @param reason - value thrown or rejected by a Client listener. + * @returns wire-safe rejection fields. + */ +export function projectRemoteEventRejection(reason: unknown): RemoteEventRejection { + const record = typeof reason === 'object' && reason !== null ? reason : undefined + const name = stringProperty(record, 'name') ?? 'Error' + const message = stringProperty(record, 'message') ?? String(reason) + const code = stringProperty(record, 'code') + const details = record === undefined ? undefined : Reflect.get(record, 'details') as unknown + return { + name, + message, + ...(code === undefined ? {} : { code }), + ...(details === undefined || !isRemoteJsonValue(details) ? {} : { details }), + } +} + +/** + * Recreate a Client rejection for the Host continuation. + * @param rejection - validated wire-safe error fields. + * @returns an Error preserving the remote name, code, and JSON-safe details. + */ +export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Error { + const error = new Error(rejection.message) as Error & { code?: string; details?: unknown } + error.name = rejection.name + if (rejection.code !== undefined) error.code = rejection.code + if (rejection.details !== undefined) error.details = rejection.details + return error +} + +/** + * Test whether a value crosses JSON transport without coercion or omission. + * @param value - candidate boundary value. + * @returns whether the value is losslessly JSON-compatible. + */ +export function isRemoteJsonValue(value: unknown): boolean { + return visitJsonValue(value, new Set()) +} + +/** + * Recognize a non-empty Remote Event correlation id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value is a valid Remote Event id. + */ +export function isRemoteEventId(value: unknown): value is RemoteEventId { + return typeof value === 'string' && value.length > 0 +} + +/** + * Recognize a non-empty Remote Event Client id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value identifies one event-stream generation. + */ +export function isRemoteEventClientId(value: unknown): value is RemoteEventClientId { + return typeof value === 'string' && value.length > 0 +} + +/** + * Recognize the direct Agent identity used by a scoped Remote Event. + * @param value - untrusted wire value. + * @returns whether the value is a non-empty Agent identity. + */ +export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentId { + return typeof value === 'string' && value.length > 0 +} + +/** One logical stream request sent from the browser. */ +export type RemoteStreamClientMessage = + | { + readonly type: 'open' + readonly streamId: string + readonly endpoint: string + readonly payload: unknown + } + | { readonly type: 'cancel'; readonly streamId: string } + +/** Carrier-safe failure delivered by the Host. */ +export interface RemoteStreamFailure { + readonly code: string + readonly message: string + readonly details: object +} + +/** One logical stream frame sent from the Host. */ +export type RemoteStreamServerMessage = + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: 'end'; readonly streamId: string } + +/** + * Parse and validate one browser-to-Host text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream request. + */ +export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage { + return parseMessage(text, (value) => { + if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) { + return value as unknown as RemoteStreamClientMessage + } + if (value.type === 'open' + && exactKeys(value, ['type', 'streamId', 'endpoint', 'payload']) + && validId(value.streamId) + && typeof value.endpoint === 'string' + && value.endpoint.length > 0) { + return value as unknown as RemoteStreamClientMessage + } + throw new Error('api gateway: invalid Remote stream client message') + }) +} + +/** + * Parse and validate one Host-to-browser text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream frame. + */ +export function parseRemoteStreamServerMessage(text: string): RemoteStreamServerMessage { + return parseMessage(text, (value) => { + if (value.type === 'item' + && (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value'])) + && validId(value.streamId)) { + return value as unknown as RemoteStreamServerMessage + } + if (value.type === 'end' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) { + return value as unknown as RemoteStreamServerMessage + } + if (value.type === 'error' + && exactKeys(value, ['type', 'streamId', 'error']) + && validId(value.streamId) + && isRecord(value.error) + && exactKeys(value.error, ['code', 'message', 'details']) + && typeof value.error.code === 'string' + && typeof value.error.message === 'string' + && isRecord(value.error.details)) { + return value as unknown as RemoteStreamServerMessage + } + throw new Error('api gateway: invalid Remote stream server message') + }) +} + +function parseMessage(text: string, validate: (value: Record) => T): T { + let decoded: unknown + try { + decoded = JSON.parse(text) as unknown + } catch (cause) { + throw new Error('api gateway: Remote stream message is not JSON', { cause }) + } + if (!isRecord(decoded)) throw new Error('api gateway: Remote stream message must be an object') + return validate(decoded) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) +} + +function isPlainRecord(value: unknown): value is Record { + if (!isRecord(value)) return false + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Reflect.ownKeys(value) + return keys.length === expected.length && expected.every(key => Object.hasOwn(value, key)) +} + +function validId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function parseRemoteEventRejection(value: unknown): RemoteEventRejection { + if (!isRecord(value) + || !hasOnlyKeys(value, ['name', 'message'], ['code', 'details']) + || typeof value.name !== 'string' + || value.name.length === 0 + || typeof value.message !== 'string' + || (Object.hasOwn(value, 'code') && typeof value.code !== 'string') + || (Object.hasOwn(value, 'details') && !isRemoteJsonValue(value.details))) { + throw new Error('api gateway: invalid Remote event rejection') + } + return { + name: value.name, + message: value.message, + ...(typeof value.code === 'string' ? { code: value.code } : {}), + ...(Object.hasOwn(value, 'details') ? { details: value.details } : {}), + } +} + +function hasOnlyKeys( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + const keys = Reflect.ownKeys(value) + return required.every(key => Object.hasOwn(value, key)) + && keys.every(key => typeof key === 'string' && (required.includes(key) || optional.includes(key))) +} + +function stringProperty(value: object | undefined, key: string): string | undefined { + if (value === undefined) return undefined + const candidate: unknown = Reflect.get(value, key) + return typeof candidate === 'string' ? candidate : undefined +} + +function visitJsonValue(value: unknown, ancestors: Set): boolean { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) + if (typeof value !== 'object') return false + if (ancestors.has(value)) return false + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype + || Reflect.ownKeys(value).length !== value.length + 1) return false + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false + } + return true + } + const prototype: unknown = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return false + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return false + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false + } + return true + } finally { + ancestors.delete(value) + } +} diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts new file mode 100644 index 0000000000..bf7711b81b --- /dev/null +++ b/packages/api/gateway/src/stream-server.ts @@ -0,0 +1,188 @@ +/** Host WebSocket owner for multiplexed Typert Remote streams. */ + +import type { IncomingMessage } from 'node:http' +import type { Duplex } from 'node:stream' +import WebSocket, { WebSocketServer, type RawData } from 'ws' +import { + parseRemoteStreamClientMessage, + type RemoteStreamFailure, + type RemoteStreamServerMessage, +} from './stream-protocol.ts' + +/** Open one validated Remote stream for a decoded wire request. */ +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => Promise> + +/** Convert an invocation or carrier failure to a stable wire value. */ +export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure + +/** Own the no-server WebSocket acceptor and every active logical stream. */ +export class RemoteStreamMuxServer { + private readonly server = new WebSocketServer({ noServer: true }) + private readonly connections = new Set>() + + /** + * @param open - Gateway stream dispatcher. + * @param failure - Gateway error-to-wire mapper. + */ + constructor( + private readonly open: RemoteStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + ) {} + + /** + * Upgrade one trusted request and begin serving its logical streams. + * @param req - authenticated HTTP upgrade request. + * @param socket - carrier socket transferred to the WebSocket server. + * @param head - bytes already read after the HTTP upgrade headers. + */ + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { + this.server.handleUpgrade(req, socket, head, (websocket) => { + const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) + const done = connection.run() + this.connections.add(done) + void done.then(() => { this.connections.delete(done) }) + }) + } + + /** Terminate all sockets and wait until every iterator has returned. */ + async close(): Promise { + for (const socket of this.server.clients) socket.terminate() + const closed = Promise.withResolvers() + this.server.close((error) => { + if (error === undefined) closed.resolve() + else closed.reject(error) + }) + await closed.promise + await Promise.all(this.connections) + } +} + +interface ActiveStream { + readonly abort: AbortController + done: Promise +} + +class RemoteStreamMuxConnection { + private readonly streams = new Map() + private writes = Promise.resolve() + + constructor( + private readonly socket: WebSocket, + private readonly open: RemoteStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + ) {} + + async run(): Promise { + const closed = new Promise((resolve) => { + this.socket.once('close', resolve) + this.socket.once('error', () => { this.socket.terminate() }) + this.socket.on('message', (data, isBinary) => { + if (isBinary) { + this.socket.close(1003, 'text messages required') + return + } + try { + this.receive(rawText(data)) + } catch { + this.socket.close(1008, 'invalid Remote stream request') + } + }) + }) + await closed + const active = [...this.streams.values()] + for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed')) + await Promise.all(active.map(stream => stream.done)) + } + + private receive(text: string): void { + const message = parseRemoteStreamClientMessage(text) + if (message.type === 'cancel') { + this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled')) + return + } + if (this.streams.has(message.streamId)) { + throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`) + } + const abort = new AbortController() + const active: ActiveStream = { + abort, + done: Promise.resolve(), + } + this.streams.set(message.streamId, active) + const done = this.pump(message.streamId, message.endpoint, message.payload, active) + active.done = done + const remove = (): void => { this.streams.delete(message.streamId) } + void done.then(remove, remove) + } + + private async pump( + streamId: string, + endpoint: string, + payload: unknown, + active: ActiveStream, + ): Promise { + try { + const source = await this.open(endpoint, payload, active.abort.signal) + for await (const value of source) { + await this.send({ type: 'item', streamId, value }) + } + if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId }) + } catch (error) { + if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) { + try { + await this.send({ type: 'error', streamId, error: this.failure(error) }) + } catch { + // A terminal frame that cannot be encoded or written leaves the + // logical stream ambiguous, so fail the physical generation. + this.socket.close(1011, 'Remote stream failure could not be delivered') + } + } + } + } + + private send(message: RemoteStreamServerMessage): Promise { + let text: string + try { + text = JSON.stringify(message) + } catch (cause) { + return Promise.reject(new Error('api gateway: Remote stream item is not JSON serializable', { cause })) + } + const delivery = this.writes.then(() => new Promise((resolve, reject) => { + if (this.socket.readyState !== WebSocket.OPEN) { + reject(new Error('api gateway: Remote stream socket is closed')) + return + } + this.socket.send(text, (error) => { + if (error) reject(error) + else resolve() + }) + })) + this.writes = delivery.catch(() => undefined) + return delivery + } +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} + +/** + * Reject an upgrade without transferring socket ownership to ws. + * @param socket - carrier socket that receives the HTTP rejection. + */ +export function rejectRemoteStreamUpgrade(socket: Duplex): void { + socket.end([ + 'HTTP/1.1 403 Forbidden', + 'Connection: close', + 'Content-Type: text/plain; charset=utf-8', + 'Content-Length: 9', + '', + 'forbidden', + ].join('\r\n')) +} diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 581e1aa2ce..b41f35e905 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -3,6 +3,8 @@ * @module @deepseek-ai/dsh-api-gateway/types */ +import type { Context } from '@deepseek-ai/cordis' + /** One Remote method request after a carrier has decoded its envelope. */ export interface InvokeRemoteRequest { /** Remote namespace selected by the generated descriptor. */ @@ -15,6 +17,85 @@ export interface InvokeRemoteRequest { readonly signal?: AbortSignal } +/** One Host Cordis notification forwarded unchanged to Client Remote subscribers. */ +export interface TypertRemoteEventFrame { + /** Original Host Cordis event name. */ + readonly event: string + /** Original event argument list after the owner validates it for JSON transport. */ + readonly args: readonly unknown[] +} + +/** Live Host values used to project one scoped Remote Event. */ +export interface TypertRemoteEventContext { + /** Live Host Context identified by the registered Host adapters. */ + readonly value: Context + /** Agent object carried directly by the waterfall request. */ + readonly subject: object +} + +/** Result returned from a Client waterfall, or delegation back to the Host chain. */ +export type TypertRemoteEventOutcome = + | { readonly kind: 'result'; readonly value: unknown } + | { readonly kind: 'next' } + +/** + * One scoped waterfall invocation yielded by the application event source. + * The Gateway alone assigns transport ids and resolves the continuation after + * a Client result or explicit delegation. + */ +export interface TypertRemoteEventInvocation { + /** Original Host Cordis event name. */ + readonly event: string + /** Sole request argument before the waterfall's `next()` callback. */ + readonly request: object + readonly context: TypertRemoteEventContext + /** Resume the source's Cordis listener with a Client result or `next()`. */ + readonly resolve: (outcome: TypertRemoteEventOutcome) => void + /** Reject the source's Cordis listener after cancellation, transport failure, or Client rejection. */ + readonly reject: (reason: unknown) => void +} + +/** Notification or scoped waterfall accepted from the sole Remote Event source. */ +export type TypertRemoteEventDispatch = TypertRemoteEventFrame | TypertRemoteEventInvocation + +/** + * Open the application-selected event stream for one Client carrier. The + * factory must attach all incremental Host listeners before it returns; the + * Gateway publishes its readiness item immediately afterward. + * @param signal - cancellation shared with the Client stream and registration. + * @returns the long-lived stream of notifications and scoped waterfall invocations. + */ +export type TypertRemoteEventSource = ( + signal: AbortSignal, +) => AsyncIterable + +/** Carrier-facing access to decoded Remote streams and their stable failures. */ +export interface TypertGatewayWireStream { + /** + * Open one logical stream from its wire endpoint and payload. + * @param endpoint - canonical Remote endpoint or Gateway-owned stream name. + * @param payload - decoded carrier payload. + * @param signal - logical-stream cancellation. + * @returns validated stream values. + */ + readonly open: ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ) => Promise> + + /** + * Convert a stream failure to the carrier-safe Remote failure fields. + * @param error - failure raised while opening or consuming a stream. + * @returns stable code, message, and details for the Client. + */ + readonly failure: (error: unknown) => { + readonly code: string + readonly message: string + readonly details: object + } +} + /** Stable infrastructure and boundary failures emitted before or after business execution. */ export type TypertGatewayErrorCode = | 'ambiguous-endpoint' @@ -37,13 +118,30 @@ export type TypertGatewayErrorCode = /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { + /** Carrier adapter shared by WebSocket and in-process transports. */ + readonly wireStream: TypertGatewayWireStream + + /** + * Register the application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this exact source and cancelling its active streams. + */ + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. - * @returns the validated business result. + * @returns the business result without output decoding. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns a cancellation-aware iterable over the business results. + */ + stream(request: InvokeRemoteRequest): Promise> } declare module '@deepseek-ai/cordis' { diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts new file mode 100644 index 0000000000..a278cc6acd --- /dev/null +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -0,0 +1,347 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +import { + RemoteStreamCarrierError, + RemoteStream, +} from '../src/client/index.ts' + +const DESCRIPTION = { + version: 'fixture', + cwd: '/fixture', + attachedSessions: 0, + home: '/home/fixture', + canOpenPath: true, +} + +function hostSource(initiallyAvailable: boolean): { + connection: Pick + publish(available: boolean): void +} { + let current = initiallyAvailable ? DESCRIPTION : undefined + const listeners = new Set<() => void>() + return { + connection: { + hostDescription: { + getSnapshot: () => current, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }, + }, + publish: (available) => { + current = available ? DESCRIPTION : undefined + for (const listener of listeners) listener() + }, + } +} + +interface Generation { + readonly values?: readonly (Item | Promise)[] + readonly terminal?: Error + readonly hold?: boolean + readonly afterAbortError?: Error + readonly close?: () => Promise +} + +function scripted(generations: Generation[], opened?: () => void) { + return (signal: AbortSignal): AsyncIterable => ({ + async * [Symbol.asyncIterator](): AsyncIterator { + const generation = generations.shift() + if (generation === undefined) throw new Error('fixture has no stream generation') + opened?.() + try { + for (const value of generation.values ?? []) yield await value + if (generation.terminal !== undefined) throw generation.terminal + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + if (generation.afterAbortError !== undefined) throw generation.afterAbortError + } finally { + await generation.close?.() + } + }, + }) +} + +function supervisor( + connection: Pick, + generations: Generation[], + carrierFailed?: (error: RemoteStreamCarrierError) => void, +): RemoteStream { + return new RemoteStream(connection, { + name: 'fixture stream', + open: scripted(generations), + ended: accepted => accepted + ? new RemoteStreamCarrierError('accepted generation ended') + : new Error('generation ended before acceptance'), + ...(carrierFailed === undefined ? {} : { carrierFailed }), + }) +} + +describe('RemoteStream', () => { + it('annotates replacement generations and resets retry state after acceptance', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [ + { values: ['first'], terminal: new RemoteStreamCarrierError('first lost') }, + { values: ['second'], hold: true }, + ]) + const iterator = stream[Symbol.asyncIterator]() + + const first = await iterator.next() + expect(first).toMatchObject({ done: false, value: { generation: 1, value: 'first' } }) + if (first.done) throw new Error('fixture generation ended early') + first.value.accept() + const second = await iterator.next() + expect(second).toMatchObject({ done: false, value: { generation: 2, value: 'second' } }) + if (second.done) throw new Error('fixture replacement ended early') + second.value.accept() + + await stream.dispose() + }) + + it('permits one isolated retry while the Host remains available', async () => { + const source = hostSource(true) + const first = new RemoteStreamCarrierError('first carrier failure') + const repeated = new RemoteStreamCarrierError('isolated retry failed') + const carrierFailed = vi.fn<(error: RemoteStreamCarrierError) => void>() + const stream = supervisor(source.connection, [ + { terminal: first }, + { terminal: repeated }, + ], carrierFailed) + + await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(repeated) + expect(carrierFailed).toHaveBeenNthCalledWith(1, first) + expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated) + }) + + it('waits for a replacement Host generation after observing unavailability', async () => { + let available = false + let listener: (() => void) | undefined + const subscribed = Promise.withResolvers() + const connection = { + hostDescription: { + getSnapshot: () => available ? DESCRIPTION : undefined, + subscribe: (value: () => void) => { + listener = value + subscribed.resolve(undefined) + return () => { listener = undefined } + }, + }, + } + let opened = 0 + const stream = new RemoteStream(connection, { + name: 'fixture stream', + open: scripted([ + { terminal: new RemoteStreamCarrierError('offline') }, + { values: ['ready'], hold: true }, + ], () => { opened++ }), + ended: () => new Error('ended'), + }) + const pending = stream[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(opened).toBe(1) }) + await subscribed.promise + + listener?.() + expect(opened).toBe(1) + available = true + listener?.() + await expect(pending).resolves.toMatchObject({ + done: false, + value: { generation: 2, value: 'ready' }, + }) + await stream.dispose() + }) + + it('stops a pending retry when the logical stream is disposed', async () => { + const source = hostSource(false) + let opened = 0 + const stream = new RemoteStream(source.connection, { + name: 'fixture stream', + open: scripted([ + { terminal: new RemoteStreamCarrierError('offline') }, + ], () => { opened++ }), + ended: () => new Error('ended'), + }) + const pending = stream[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(opened).toBe(1) }) + source.publish(false) + + await stream.dispose() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + }) + + it('contains a Host publication during subscription setup', async () => { + let reads = 0 + let disposed = 0 + const connection = { + hostDescription: { + getSnapshot: () => reads++ === 0 ? undefined : DESCRIPTION, + subscribe: (listener: () => void) => { + listener() + return () => { disposed++ } + }, + }, + } + const stream = supervisor(connection, [ + { terminal: new RemoteStreamCarrierError('offline') }, + { values: ['ready'], hold: true }, + ]) + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + value: { generation: 2, value: 'ready' }, + }) + expect(disposed).toBe(1) + await stream.dispose() + }) + + it('restarts with a fresh physical generation', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [ + { values: ['first'], hold: true }, + { values: ['second'], hold: true }, + ]) + const iterator = stream[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 1, value: 'first' } }) + + stream.restart() + + await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 2, value: 'second' } }) + await stream.dispose() + }) + + it('drops values and cancellation failures from a replaced generation', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [ + { values: ['first', 'stale'] }, + { + values: ['second'], + hold: true, + afterAbortError: new Error('replaced generation cancelled'), + }, + { values: ['third'], hold: true }, + ]) + const iterator = stream[Symbol.asyncIterator]() + const first = await iterator.next() + if (first.done) throw new Error('fixture generation ended early') + + stream.restart() + first.value.accept() + await expect(iterator.next()).resolves.toMatchObject({ + value: { generation: 2, value: 'second' }, + }) + + stream.restart() + await expect(iterator.next()).resolves.toMatchObject({ + value: { generation: 3, value: 'third' }, + }) + await stream.dispose() + }) + + it('honors replacement requested by carrier diagnostics', async () => { + const source = hostSource(true) + const holder: { stream?: RemoteStream } = {} + const carrierFailed = vi.fn(() => { holder.stream?.restart() }) + const stream = supervisor(source.connection, [ + { terminal: new RemoteStreamCarrierError('replace this generation') }, + { values: ['ready'], hold: true }, + ], carrierFailed) + holder.stream = stream + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + value: { generation: 2, value: 'ready' }, + }) + expect(carrierFailed).toHaveBeenCalledOnce() + await stream.dispose() + }) + + it('contains replacement during Host-readiness subscription setup', async () => { + const holder: { stream?: RemoteStream } = {} + let subscriptions = 0 + const connection = { + hostDescription: { + getSnapshot: () => undefined, + subscribe: () => { + subscriptions++ + holder.stream?.restart() + return () => {} + }, + }, + } + const stream = supervisor(connection, [ + { terminal: new RemoteStreamCarrierError('offline') }, + { values: ['ready'], hold: true }, + ]) + holder.stream = stream + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + value: { generation: 2, value: 'ready' }, + }) + expect(subscriptions).toBe(1) + await stream.dispose() + }) + + it('waits for generation cleanup during disposal', async () => { + const source = hostSource(true) + const release = Promise.withResolvers() + let closed = false + const stream = supervisor(source.connection, [{ + values: ['ready'], + hold: true, + close: async () => { + await release.promise + closed = true + }, + }]) + const iterator = stream[Symbol.asyncIterator]() + await iterator.next() + const pending = iterator.next() + + const disposing = stream.dispose() + expect(stream.dispose()).toBe(disposing) + await Promise.resolve() + expect(closed).toBe(false) + release.resolve(undefined) + + await expect(disposing).resolves.toBeUndefined() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(closed).toBe(true) + }) + + it('uses the domain normal-end classification and permits one consumer', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [{}]) + const iterator = stream[Symbol.asyncIterator]() + + expect(() => stream[Symbol.asyncIterator]()).toThrow('already has a consumer') + await expect(iterator.next()).rejects.toThrow('generation ended before acceptance') + await stream.dispose() + }) + + it('can be disposed before consumption and ignores later restart', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, []) + + await stream.dispose() + expect(stream.signal.aborted).toBe(true) + stream.restart() + await expect(stream[Symbol.asyncIterator]().next()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('drops a value that arrives after disposal begins', async () => { + const source = hostSource(true) + const late = Promise.withResolvers() + const stream = supervisor(source.connection, [{ values: [late.promise] }]) + const pending = stream[Symbol.asyncIterator]().next() + const disposing = stream.dispose() + late.resolve('late') + + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + await disposing + }) +}) diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts new file mode 100644 index 0000000000..cf95c84f27 --- /dev/null +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -0,0 +1,1083 @@ +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket, { type RawData } from 'ws' +import { Context, Service, symbols } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { + bindTypertRemote, + Remote, + type InvocationDescriptor, + type TypertContextMap, + type TypertContextWire, + TypertRemoteFailure, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import TypertGatewayService, { + TypertGatewayError, + type TypertRemoteEventDispatch, + type TypertRemoteEventInvocation, + type TypertRemoteEventOutcome, +} from '@deepseek-ai/dsh-api-gateway' +import { z } from 'zod' +import type { + RemoteEventClientId, + RemoteEventInvocationFrame, +} from '../src/stream-protocol.ts' + +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, randomUUID: vi.fn(actual.randomUUID) } +}) + +const randomUuid = vi.mocked(randomUUID) +type AgentWireId = TypertContextWire +const agentId = (value: string): AgentWireId => value as AgentWireId + +class FeedService extends Service { + readonly typertRemote = bindTypertRemote(this, 'feed') + readonly signals: AbortSignal[] = [] + returns = 0 + + constructor(ctx: Context) { + super(ctx, 'feed') + } + + @Remote({ mode: 'stream' }) + async *follow(label: string, signal: AbortSignal): AsyncIterable { + this.signals.push(signal) + try { + yield `${label}:ready` + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + this.returns += 1 + } + } + + @Remote({ mode: 'stream' }) + *sync(label: string): Iterable { + yield `${label}:one` + yield `${label}:two` + } + + @Remote({ mode: 'stream' }) + *invalid(): Iterable { + yield 42 as unknown as string + } + + @Remote({ mode: 'stream' }) + *nonJson(): Iterable { + yield 1n + } + + @Remote({ mode: 'stream' }) + missing(): Iterable { + return null as unknown as Iterable + } + + @Remote({ mode: 'stream' }) + *src(label: string): Iterable { + yield `${label}:src` + } + + @Remote({ mode: 'stream' }) + abortBeforeOpen(signal: AbortSignal): Iterable { + if (signal.aborted) throw new Error('fixture observed pre-open cancellation') + return [] + } + + @Remote({ mode: 'stream' }) + reject(): Iterable { + throw new TypertRemoteFailure({ + code: 'fixture-rejected', message: 'fixture rejected the stream', details: { retryable: false }, + }) + } + + @Remote({ mode: 'stream' }) + rejectWithNonJsonDetails(): Iterable { + throw new TypertRemoteFailure({ + code: 'fixture-broken', message: 'fixture emitted invalid details', details: { count: 1n }, + }) + } + + unary(label: string): string { + return label + } +} + +const roots: Context[] = [] + +class RemoteEventSourceProbe { + readonly source = (signal: AbortSignal): AsyncIterable => { + this.signal = signal + return this.iterate(signal) + } + + signal: AbortSignal | undefined + private readonly dispatches: TypertRemoteEventDispatch[] = [] + private wake: (() => void) | undefined + + push(dispatch: TypertRemoteEventDispatch): void { + this.dispatches.push(dispatch) + this.wake?.() + this.wake = undefined + } + + private async *iterate(signal: AbortSignal): AsyncGenerator { + const aborted = (): void => { + this.wake?.() + this.wake = undefined + } + signal.addEventListener('abort', aborted, { once: true }) + try { + while (!signal.aborted) { + while (this.dispatches.length > 0) { + yield this.dispatches.shift() as TypertRemoteEventDispatch + } + if (signal.aborted) return + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } finally { + signal.removeEventListener('abort', aborted) + } + } +} + +interface PendingInvocationProbe { + readonly dispatch: TypertRemoteEventInvocation + readonly outcome: Promise + readonly resolve: (outcome: TypertRemoteEventOutcome) => void + readonly reject: (reason: unknown) => void +} + +function pendingInvocation( + context: Context, + signal?: AbortSignal, + prompt = 'ship', +): PendingInvocationProbe { + const subject = { ctx: context } + const settled = Promise.withResolvers() + const resolve = vi.fn((outcome: TypertRemoteEventOutcome) => { + settled.resolve(outcome) + }) + const reject = vi.fn((reason: unknown) => { + settled.reject(reason) + }) + return { + dispatch: { + event: 'fixture/approval', + request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) }, + context: { value: context, subject }, + resolve, + reject, + }, + outcome: settled.promise, + resolve, + reject, + } +} + +afterEach(async () => { + randomUuid.mockClear() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('Typert Remote streams', () => { + it('opens decoded carrier payloads through the in-process wire adapter', async () => { + const { ctx } = await setup(false) + const source = await ctx.typertGateway.wireStream.open( + 'feed/sync', + { args: { label: 'wire' } }, + new AbortController().signal, + ) + + await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) + }) + + it('passes Iterable and AsyncIterable items through and returns the iterator on cancellation', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const source = await ctx.typertGateway.stream({ + namespace: 'feed', + method: 'follow', + args: { label: 'a' }, + signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'a:ready' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted') + expect(service.signals).toEqual([abort.signal]) + expect(service.returns).toBe(1) + + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'sync', args: { label: 'b' }, + }))).resolves.toEqual(['b:one', 'b:two']) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'invalid', args: {}, + }))).resolves.toEqual([42]) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'nonJson', args: {}, + }))).resolves.toEqual([1n]) + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'missing', args: {}, + })).rejects.toMatchObject({ code: 'result-invalid' }) + + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'src', args: { label: 'c' }, + }))).resolves.toEqual(['c:src']) + + const abortedBeforeOpen = new AbortController() + abortedBeforeOpen.abort(new Error('cancelled before open')) + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal, + })).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted') + + const abortedBeforeIteration = new AbortController() + abortedBeforeIteration.abort(new Error('cancelled before iteration')) + const preCancelled = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal, + }) + await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted') + }) + + it('keeps unary and stream invocation modes distinct', async () => { + const { ctx } = await setup(false) + await expect(ctx.typertGateway.invoke({ + namespace: 'feed', method: 'sync', args: { label: 'a' }, + })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'unary', args: { label: 'a' }, + })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + }) + + it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => { + const { ctx, service } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'a', 'feed/follow', { label: 'a' }) + sendOpen(socket, 'b', 'feed/follow', { label: 'b' }) + await vi.waitFor(() => { + expect(frames).toEqual(expect.arrayContaining([ + { type: 'item', streamId: 'a', value: 'a:ready' }, + { type: 'item', streamId: 'b', value: 'b:ready' }, + ])) + }) + expect(service.signals.map(signal => signal.aborted)).toEqual([false, false]) + expect(service.returns).toBe(0) + + socket.send(JSON.stringify({ type: 'cancel', streamId: 'a' })) + await vi.waitFor(() => { expect(service.returns).toBe(1) }) + expect(service.signals[0]?.aborted).toBe(true) + expect(service.signals[1]?.aborted).toBe(false) + + sendOpen(socket, 'sync', 'feed/sync', { label: 's' }) + sendOpen(socket, 'invalid', 'feed/invalid', {}) + sendOpen(socket, 'non-json', 'feed/nonJson', {}) + sendOpen(socket, 'rejected', 'feed/reject', {}) + await vi.waitFor(() => { + expect(frames.filter(frame => frame.streamId === 'sync')).toEqual([ + { type: 'item', streamId: 'sync', value: 's:one' }, + { type: 'item', streamId: 'sync', value: 's:two' }, + { type: 'end', streamId: 'sync' }, + ]) + expect(frames.filter(frame => frame.streamId === 'invalid')).toEqual([ + { type: 'item', streamId: 'invalid', value: 42 }, + { type: 'end', streamId: 'invalid' }, + ]) + expect(frames.find(frame => frame.streamId === 'non-json')).toMatchObject({ + type: 'error', error: { code: 'internal' }, + }) + expect(frames.find(frame => frame.streamId === 'rejected')).toEqual({ + type: 'error', + streamId: 'rejected', + error: { + code: 'fixture-rejected', + message: 'fixture rejected the stream', + details: { retryable: false }, + }, + }) + }) + + const closed = once(socket, 'close') + sendOpen(socket, 'broken-error', 'feed/rejectWithNonJsonDetails', {}) + const closeEvent = await closed + expect(closeEvent[0]).toBe(1011) + expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') + await vi.waitFor(() => { expect(service.returns).toBe(2) }) + expect(service.signals[1]?.aborted).toBe(true) + }) + + it('carries the registered Remote event source and withdraws its active stream', async () => { + const { ctx } = await setup(true) + let sourceSignal: AbortSignal | undefined + const sourceClosed = vi.fn() + const publish = Promise.withResolvers() + const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => { + sourceSignal = signal + return (async function *() { + try { + await publish.promise + yield { event: 'fixture/changed', args: ['settings'] } + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + sourceClosed() + } + })() + } + const unregister = ctx.typertGateway.registerRemoteEvents(source) + expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + .toThrow('forwarded Remote event source is already registered') + + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + sendOpen(socket, 'events', '$events', {}) + + await vi.waitFor(() => { + const eventFrames = frames.filter(frame => frame.streamId === 'events') + expect(eventFrames).toHaveLength(1) + expect(eventFrames[0]).toMatchObject({ + type: 'item', streamId: 'events', value: { type: 'ready' }, + }) + expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') + }) + publish.resolve(undefined) + await vi.waitFor(() => { + const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2) + expect(eventFrames).toHaveLength(2) + expect(eventFrames[0]).toMatchObject({ + type: 'item', streamId: 'events', value: { type: 'ready' }, + }) + expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') + expect(eventFrames[1]).toEqual({ + type: 'item', streamId: 'events', value: { + type: 'emit', event: 'fixture/changed', args: ['settings'], + }, + }) + }) + expect(sourceSignal?.aborted).toBe(false) + + await unregister() + expect(sourceClosed).toHaveBeenCalledOnce() + await vi.waitFor(() => { + expect(sourceSignal?.aborted).toBe(true) + expect(frames).toContainEqual({ type: 'end', streamId: 'events' }) + }) + + const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source) + await unregister() + expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + .toThrow('forwarded Remote event source is already registered') + await unregisterReplacement() + socket.close() + }) + + it('rejects a scoped dispatch yielded after its Remote event source is withdrawn', async () => { + const { ctx } = await setup(false) + const publish = Promise.withResolvers() + const agent = ctx.extend() + const pending = pendingInvocation(agent) + const source = (): AsyncIterable => (async function* () { + await publish.promise + yield pending.dispatch + })() + const unregister = ctx.typertGateway.registerRemoteEvents(source) + const rejected = expect(pending.outcome).rejects.toThrow( + 'forwarded Remote event source was removed', + ) + + publish.resolve(undefined) + await unregister() + + await rejected + expect(pending.reject).toHaveBeenCalledTimes(1) + expect(pending.resolve).not.toHaveBeenCalled() + }) + + it('cancels a pending waterfall when its source rejects during removal', async () => { + const { ctx } = await setup(true) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-removal') : undefined, + resolve: id => id === 'agent-removal' ? agent : undefined, + }) + const pending = pendingInvocation(agent) + const rejected = expect(pending.outcome).rejects.toThrow( + 'forwarded Remote event source was removed', + ) + const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () { + yield pending.dispatch + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + throw new Error('fixture source rejected during removal') + })()) + const client = await openEventClient(ctx, 'events-removal') + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + + await unregister() + await rejected + expect(pending.reject).toHaveBeenCalledTimes(1) + expect(pending.resolve).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(client.frames).toContainEqual({ type: 'end', streamId: client.streamId }) + }) + client.socket.close() + }) + + it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => { + const { ctx } = await setup(false) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + + for (const event of [42, ''] as const) { + const invalidName = pendingInvocation(ctx) + const rejected = expect(invalidName.outcome).rejects.toThrow( + 'Remote event name must be a nonempty string', + ) + source.push({ + ...invalidName.dispatch, + event: event as unknown as string, + }) + await rejected + } + + const unavailable = pendingInvocation(ctx) + source.push(unavailable.dispatch) + await expect(unavailable.outcome).resolves.toEqual({ kind: 'next' }) + expect(unavailable.reject).not.toHaveBeenCalled() + + let selected = ctx.extend() + let identity: unknown = 1n + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === selected ? identity as AgentWireId : undefined, + resolve: () => selected, + }) + const nonJsonIdentity = pendingInvocation(selected) + const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow( + 'require a non-empty Agent identity', + ) + source.push(nonJsonIdentity.dispatch) + await nonJsonRejected + + identity = 'agent-invalid-request' + const invalidRequest = pendingInvocation(selected) + const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow( + 'must carry its scoped Agent directly', + ) + source.push({ + ...invalidRequest.dispatch, + request: {}, + }) + await invalidRequestRejected + + const staleFiber = ctx.plugin(() => {}) + await staleFiber + selected = staleFiber.ctx + identity = 'agent-stale' + await staleFiber.dispose() + const stale = pendingInvocation(selected) + source.push(stale.dispatch) + await expect(stale.outcome).resolves.toEqual({ kind: 'next' }) + expect(stale.reject).not.toHaveBeenCalled() + + selected = ctx.extend() + identity = 'agent-cancelled' + const abort = new AbortController() + abort.abort('fixture non-error cancellation') + const cancelled = pendingInvocation(selected, abort.signal) + const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({ + message: 'typert gateway: Remote event was cancelled', + cause: 'fixture non-error cancellation', + }) + source.push(cancelled.dispatch) + await cancelledOutcome + + await unregister() + }) + + it('rejects notification arguments that are not lossless JSON arrays', async () => { + const { ctx } = await setup(false) + const frames = [ + { event: 'fixture/changed', args: {} }, + { event: 'fixture/changed', args: [1n] }, + ] + for (const frame of frames) { + let sourceSignal: AbortSignal | undefined + const unregister = ctx.typertGateway.registerRemoteEvents((signal) => { + sourceSignal = signal + return (async function* () { + yield frame as unknown as TypertRemoteEventDispatch + })() + }) + await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) + const reason: unknown = sourceSignal?.reason + if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error') + expect(reason.message).toContain('arguments are not lossless JSON data') + await unregister() + } + }) + + it('retries a colliding Remote event id before publishing the second waterfall', async () => { + const { ctx } = await setup(false) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-collision') : undefined, + resolve: id => id === 'agent-collision' ? agent : undefined, + }) + const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType + const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType + randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) + const firstAbort = new AbortController() + const secondAbort = new AbortController() + const first = pendingInvocation(agent, firstAbort.signal, 'first') + const second = pendingInvocation(agent, secondAbort.signal, 'second') + + source.push(first.dispatch) + await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) + source.push(second.dispatch) + await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(3) }) + + const firstReason = new Error('cancel first collision fixture') + const secondReason = new Error('cancel second collision fixture') + const firstRejected = expect(first.outcome).rejects.toBe(firstReason) + const secondRejected = expect(second.outcome).rejects.toBe(secondReason) + firstAbort.abort(firstReason) + secondAbort.abort(secondReason) + await firstRejected + await secondRejected + await unregister() + }) + + it('retries a colliding Remote event Client id before opening the second generation', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType + const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType + randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) + + const first = await openEventClient(ctx, 'events-client-id-a') + const second = await openEventClient(ctx, 'events-client-id-b') + + expect(first.clientId).toBe(firstId) + expect(second.clientId).toBe(secondId) + expect(randomUuid).toHaveBeenCalledTimes(3) + first.socket.close() + second.socket.close() + await unregister() + }) + + it('fans one scoped waterfall out and accepts the first Client result', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? agent : undefined, + }) + const first = await openEventClient(ctx, 'events-a') + const second = await openEventClient(ctx, 'events-b') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + + await vi.waitFor(() => { + expect(deliveredInvocation(first)).toBeDefined() + expect(deliveredInvocation(second)).toBeDefined() + }) + const firstFrame = deliveredInvocation(first)! + const secondFrame = deliveredInvocation(second)! + expect(firstFrame.eventId).toBe(secondFrame.eventId) + expect(firstFrame).toMatchObject({ + type: 'waterfall', + event: 'fixture/approval', + agentId: 'agent-1', + request: { prompt: 'ship' }, + }) + expect(firstFrame).not.toHaveProperty('deliveryId') + expect(secondFrame).not.toHaveProperty('deliveryId') + + await sendEventResult(second, secondFrame, { + kind: 'result', value: 'allowed', + }) + await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' }) + await vi.waitFor(() => { + expect(first.frames).toContainEqual({ + type: 'item', + streamId: first.streamId, + value: { type: 'cancel', eventId: firstFrame.eventId }, + }) + }) + + await sendEventResult(first, firstFrame, { + kind: 'result', value: 'rejected', + }) + expect(pending.resolve).toHaveBeenCalledTimes(1) + expect(pending.reject).not.toHaveBeenCalled() + first.socket.close() + second.socket.close() + await unregister() + }) + + it('rejects the Host waterfall with the first Client listener rejection', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-rejected') : undefined, + resolve: id => id === 'agent-rejected' ? agent : undefined, + }) + const client = await openEventClient(ctx, 'events-rejected') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + const frame = deliveredInvocation(client)! + const rejected = expect(pending.outcome).rejects.toMatchObject({ + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }) + + await sendEventResult(client, frame, { + kind: 'rejected', + error: { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }, + }) + await rejected + expect(pending.reject).toHaveBeenCalledTimes(1) + expect(pending.resolve).not.toHaveBeenCalled() + + client.socket.close() + await unregister() + }) + + it('delegates to the Host only after every active Client returns next', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? agent : undefined, + }) + const first = await openEventClient(ctx, 'events-next-a') + const second = await openEventClient(ctx, 'events-next-b') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + await vi.waitFor(() => { + expect(deliveredInvocation(first)).toBeDefined() + expect(deliveredInvocation(second)).toBeDefined() + }) + const firstFrame = deliveredInvocation(first)! + const secondFrame = deliveredInvocation(second)! + + await sendEventResult(first, firstFrame, { kind: 'next' }) + expect(pending.resolve).not.toHaveBeenCalled() + await sendEventResult(second, secondFrame, { kind: 'next' }) + await expect(pending.outcome).resolves.toEqual({ kind: 'next' }) + expect(pending.resolve).toHaveBeenCalledTimes(1) + expect(pending.reject).not.toHaveBeenCalled() + first.socket.close() + second.socket.close() + await unregister() + }) + + it('delivers a pending waterfall to the first Client that connects', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-late-client') : undefined, + resolve: id => id === 'agent-late-client' ? agent : undefined, + }) + const pending = pendingInvocation(agent, undefined, 'before-connect') + + source.push(pending.dispatch) + await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) + + const client = await openEventClient(ctx, 'events-first-client') + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + const frame = deliveredInvocation(client)! + expect(frame).toMatchObject({ + type: 'waterfall', + event: 'fixture/approval', + agentId: 'agent-late-client', + request: { prompt: 'before-connect' }, + }) + + await sendEventResult(client, frame, { kind: 'result', value: 'allowed' }) + await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' }) + + client.socket.close() + await unregister() + }) + + it('replays a pending event id to a replacement Client generation', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? agent : undefined, + }) + const original = await openEventClient(ctx, 'events-original') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + await vi.waitFor(() => { expect(deliveredInvocation(original)).toBeDefined() }) + const originalFrame = deliveredInvocation(original)! + const closed = once(original.socket, 'close') + original.socket.close() + await closed + + const replacement = await openEventClient(ctx, 'events-replacement') + await vi.waitFor(() => { expect(deliveredInvocation(replacement)).toBeDefined() }) + const replayed = deliveredInvocation(replacement)! + expect(replayed.eventId).toBe(originalFrame.eventId) + expect(replayed).not.toHaveProperty('deliveryId') + await sendEventResult(replacement, replayed, { + kind: 'result', value: 'allowed', + }) + await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' }) + + replacement.socket.close() + await unregister() + }) + + it('cancels pending deliveries when the Host signal or Context ends', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const signalAgent = ctx.extend() + const contextFiber = ctx.plugin(() => {}) + await contextFiber + const contextAgent = contextFiber.ctx + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: (candidate) => { + if (candidate === signalAgent) return agentId('agent-signal') + if (candidate === contextAgent) return agentId('agent-context') + return undefined + }, + resolve: (id) => { + if (id === 'agent-signal') return signalAgent + if (id === 'agent-context') return contextAgent + return undefined + }, + }) + const client = await openEventClient(ctx, 'events-cancel') + + const abort = new AbortController() + const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal') + source.push(signalPending.dispatch) + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + const signalFrame = deliveredInvocation(client)! + expect(signalFrame).toMatchObject({ + type: 'waterfall', + agentId: 'agent-signal', + request: { prompt: 'signal' }, + }) + const signalReason = new Error('Host caller cancelled') + const signalOutcome = expect(signalPending.outcome).rejects.toBe(signalReason) + abort.abort(signalReason) + await signalOutcome + await vi.waitFor(() => { + expect(client.frames).toContainEqual({ + type: 'item', + streamId: client.streamId, + value: { type: 'cancel', eventId: signalFrame.eventId }, + }) + }) + + const contextPending = pendingInvocation(contextAgent, undefined, 'context') + source.push(contextPending.dispatch) + let contextFrame: RemoteEventInvocationFrame | undefined + await vi.waitFor(() => { + contextFrame = client.frames + .filter(frame => frame.type === 'item' && frame.streamId === client.streamId) + .map(frame => frame.value) + .find(value => typeof value === 'object' + && value !== null + && Reflect.get(value, 'event') === 'fixture/approval' + && Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined + expect(contextFrame).toBeDefined() + }) + const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Context "agent" was released') + await contextFiber.dispose() + await contextOutcome + await vi.waitFor(() => { + expect(client.frames).toContainEqual({ + type: 'item', + streamId: client.streamId, + value: { type: 'cancel', eventId: contextFrame!.eventId }, + }) + }) + + client.socket.close() + await unregister() + }) + + it('validates the internal Remote event request and reports an absent source', async () => { + const { ctx } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'missing', '$events', {}) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'missing')?.type).toBe('error') + expect(streamErrorMessage(frames, 'missing')).toContain('source is unavailable') + }) + + let sourceCalls = 0 + const unregister = ctx.typertGateway.registerRemoteEvents(() => { + sourceCalls += 1 + return (async function *(): AsyncIterable {})() + }) + const invalidPayloads: readonly unknown[] = [ + null, + [], + {}, + { other: {} }, + { args: null }, + { args: [] }, + { args: { extra: true } }, + ] + invalidPayloads.forEach((payload, index) => { + socket.send(JSON.stringify({ + type: 'open', streamId: `invalid-${String(index)}`, endpoint: '$events', payload, + })) + }) + await vi.waitFor(() => { + expect(frames.filter(frame => String(frame.streamId).startsWith('invalid-'))).toHaveLength(invalidPayloads.length) + }) + for (const [index] of invalidPayloads.entries()) { + const streamId = `invalid-${String(index)}` + expect(frames.find(frame => frame.streamId === streamId)?.type).toBe('error') + expect(streamErrorMessage(frames, streamId)).toContain('requires an empty args object') + } + expect(sourceCalls).toBe(1) + + await unregister() + socket.close() + }) + + it('applies Connection trusted-host policy before accepting the Gateway socket', async () => { + const { ctx } = await setup(true) + const socket = new WebSocket( + `ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, + { headers: { host: 'untrusted.example' } }, + ) + socket.on('error', () => {}) + const responseEvent: unknown[] = await once(socket, 'unexpected-response') + const request = responseEvent[0] + const response = responseEvent[1] + const rejected = response as { statusCode?: number; resume(): void } + expect(rejected.statusCode).toBe(403) + rejected.resume() + ;(request as { abort(): void }).abort() + }) +}) + +async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> { + const ctx = new Context() + roots.push(ctx) + if (transport) { + await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) + } + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGatewayService) + if (transport) { + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + } + await ctx.plugin(FeedService) + ctx.typert.register({ + package: '@fixture/feed', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: descriptors(), + }) + const receiver = ctx.get('feed') as unknown as FeedService & { [symbols.original]?: FeedService } + return { ctx, service: receiver[symbols.original] ?? receiver } +} + +function descriptors(): InvocationDescriptor[] { + const label = { + name: 'label', + wire: 'label', + source: 'json' as const, + codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', schema: z.string() }, + } + const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({ + id: `@fixture/feed#feed/${method}`, + service: 'feed', + namespace: 'feed', + method, + mode: 'stream', + invocation: { kind: 'direct' }, + parameters, + result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema }, + }) + return [ + { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, + stream('sync', [label], z.string()), + stream('invalid', [], z.string()), + stream('nonJson', [], z.unknown()), + stream('missing', [], z.string()), + { ...stream('abortBeforeOpen', [], z.string()), cancellation: { parameter: 'signal' } }, + stream('reject', [], z.string()), + stream('rejectWithNonJsonDetails', [], z.string()), + { + id: '@fixture/feed#feed/unary', + service: 'feed', + namespace: 'feed', + method: 'unary', + invocation: { kind: 'direct' }, + parameters: [label], + result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema: z.string() }, + }, + ] +} + +interface RemoteEventTestClient { + readonly socket: WebSocket + readonly frames: Record[] + readonly streamId: string + readonly clientId: RemoteEventClientId + readonly origin: string +} + +async function openEventClient(ctx: Context, streamId: string): Promise { + const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + sendOpen(socket, streamId, '$events', {}) + let clientId: RemoteEventClientId | undefined + await vi.waitFor(() => { + const ready = frames.find(frame => frame.type === 'item' + && frame.streamId === streamId + && typeof frame.value === 'object' + && frame.value !== null + && Reflect.get(frame.value, 'type') === 'ready') + const candidate: unknown = ready === undefined ? undefined : Reflect.get(ready.value as object, 'clientId') + expect(typeof candidate).toBe('string') + if (typeof candidate === 'string') clientId = candidate as RemoteEventClientId + }) + if (clientId === undefined) throw new Error('Remote event stream omitted its Client id') + return { socket, frames, streamId, clientId, origin } +} + +function deliveredInvocation(client: RemoteEventTestClient): RemoteEventInvocationFrame | undefined { + for (const frame of client.frames) { + if (frame.type !== 'item' || frame.streamId !== client.streamId) continue + const value = frame.value + if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'eventId')) continue + return value as RemoteEventInvocationFrame + } + return undefined +} + +async function sendEventResult( + client: RemoteEventTestClient, + frame: RemoteEventInvocationFrame, + outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { + readonly kind: 'rejected' + readonly error: { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown + } + }, +): Promise { + const rpcId = `remote-event-result-${client.streamId}` + const response = await fetch(`${client.origin}/api/$events/result`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId, + method: '$events/result', + payload: { + args: { clientId: client.clientId, eventId: frame.eventId, outcome }, + }, + }), + }) + expect(response.status).toBe(200) + const body = await response.json() as { readonly result?: { readonly ok?: boolean; readonly error?: { message?: string } } } + if (body.result?.ok !== true) { + throw new Error(body.result?.error?.message ?? 'Remote event result failed') + } +} + +function sendOpen(socket: WebSocket, streamId: string, endpoint: string, args: object): void { + socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } })) +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} + +function streamErrorMessage(frames: readonly Record[], streamId: string): string | undefined { + const error = frames.find(frame => frame.streamId === streamId)?.error + if (typeof error !== 'object' || error === null) return undefined + const message = Reflect.get(error, 'message') as unknown + return typeof message === 'string' ? message : undefined +} + +async function collect(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 99fe477bf2..92eeecb5e0 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -2,18 +2,38 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { + apply as applyConnection, + type ConnectionGenerationSource, + type ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, RemoteResult, - TypertClientRemote, + TypertContextMap, + TypertContextWire, TypertContext, + TypertLookup, TypertRemoteScopeApi, TypertRemoteNamespace, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' -import { apply, inject } from '../src/client/index.ts' +import { apply, inject, RemoteStream } from '../src/client/index.ts' +import { + RemoteStreamCarrierError, + RemoteStreamError, + RemoteStreamMuxClient, +} from '../src/client/stream-client.ts' + +type FixtureApprovalOutcome = 'allowed' | 'unavailable' +const fixtureContextTag = Symbol('fixture-context-tag') +type AgentWireId = TypertContextWire +const agentId = (value: string): AgentWireId => value as AgentWireId + +interface FixtureAgent { + readonly agentId: string +} declare module '@deepseek-ai/cordis' { interface Events { @@ -27,6 +47,21 @@ declare module '@deepseek-ai/cordis' { * @param count - marker payload never observed. */ 'fixture/idle'(count: number): void + /** + * Test-only scoped waterfall forwarded through the existing Remote Event stream. + * @param request - JSON-safe request payload. + * @param next - delegates to the next Client listener or Host waterfall. + * @returns the claimed or delegated outcome. + */ + 'fixture/approval'( + this: Context, + request: { + readonly prompt: string + readonly agent: FixtureAgent + readonly signal?: AbortSignal + }, + next: () => Promise, + ): Promise /** * Test-only event the Host assembly does not forward. * @param flag - marker payload never delivered. @@ -36,12 +71,17 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-typert-protocol' { - interface TypertRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {} + interface TypertRemoteEventSelection extends + Record<'fixture/changed' | 'fixture/idle' | 'fixture/approval', true> {} interface TypertContextMap { fixture: TypertContext } + interface TypertLookupMap { + fixture: TypertLookup + } + interface TypertRemoteMap { 'probe/create': ( agentId: string, @@ -49,6 +89,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { signal?: AbortSignal, ) => Promise> 'probe/maybe': (value: string | null | undefined) => Promise> + 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable } interface TypertRemoteScopeMap { @@ -68,13 +109,19 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } type FixtureContext = Omit & { - readonly remote: TypertClientRemote & TypertRemoteScopeApi<'fixture'> + readonly remote: ClientRemote & TypertRemoteScopeApi<'fixture'> } // Compile-time contract of `$on`: the key face is the forwarding selection and // the listener signature is the owning package's own Cordis declaration. function remoteEventContracts(remote: ClientRemote): void { remote.$on('fixture/changed', (namespace) => { void namespace }) + remote.$on('fixture/approval', async function (request, next) { + expectTypeOf(this).toEqualTypeOf() + expectTypeOf(request.agent).toEqualTypeOf() + expectTypeOf(request.signal).toEqualTypeOf() + return request.prompt === '' ? next() : 'allowed' + }) // @ts-expect-error -- declared in Events but outside the forwarding selection. remote.$on('fixture/unselected', () => {}) // @ts-expect-error -- not declared in Events at all. @@ -155,24 +202,392 @@ function maybeDescriptor(): InvocationDescriptor { } } -async function bench(call: ConnectionHandle['rpc']['call']): Promise { - const { ctx } = await benchFiber(call) +function streamDescriptor(): InvocationDescriptor { + return { + id: '@fixture/probe#probe/watch', + service: 'probe', + namespace: 'probe', + method: 'watch', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'topic', + wire: 'topic', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#Topic', schema: z.string().min(1) }, + }], + cancellation: { parameter: 'signal' }, + result: { mode: 'strict', typeSymbol: '@fixture#WatchItem', schema: z.string().min(1) }, + } +} + +type WebSocketGlobal = { WebSocket?: typeof WebSocket } + +class FakeWebSocket extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + static readonly sockets: FakeWebSocket[] = [] + static autoOpen = true + static dispatchClose = true + + readonly url: string + readonly sent: string[] = [] + readonly closedWith: { readonly code?: number; readonly reason?: string }[] = [] + readyState = FakeWebSocket.CONNECTING + + constructor(url: string | URL) { + super() + this.url = String(url) + FakeWebSocket.sockets.push(this) + queueMicrotask(() => { + if (FakeWebSocket.autoOpen) this.open() + }) + } + + open(): void { + if (this.readyState !== FakeWebSocket.CONNECTING) return + this.readyState = FakeWebSocket.OPEN + this.dispatchEvent(new Event('open')) + } + + fail(): void { + this.dispatchEvent(new Event('error')) + } + + send(data: string): void { + if (this.readyState !== FakeWebSocket.OPEN) throw new Error('fixture socket is not open') + this.sent.push(data) + } + + close(code?: number, reason?: string): void { + this.closedWith.push({ + ...(code === undefined ? {} : { code }), + ...(reason === undefined ? {} : { reason }), + }) + if (this.readyState === FakeWebSocket.CLOSED) return + if (!FakeWebSocket.dispatchClose) { + this.readyState = FakeWebSocket.CLOSING + return + } + this.drop() + } + + drop(): void { + if (this.readyState === FakeWebSocket.CLOSED) return + this.readyState = FakeWebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + receive(value: unknown): void { + this.receiveRaw(typeof value === 'string' ? value : JSON.stringify(value)) + } + + receiveRaw(data: unknown): void { + this.dispatchEvent(new MessageEvent('message', { + data, + })) + } +} + +async function bench( + call: ConnectionHandle['rpc']['call'], + carrier: 'in-process' | 'web' = 'in-process', +): Promise { + const { ctx } = await benchFiber(call, carrier) return ctx } async function benchFiber( call: ConnectionHandle['rpc']['call'], -): Promise<{ readonly ctx: Context; readonly client: Fiber }> { + carrier: 'in-process' | 'web' = 'in-process', + open: NonNullable = () => unexpectedInProcessStream(), +): Promise<{ + readonly ctx: Context + readonly client: Fiber + readonly generation: GenerationHarness +}> { const ctx = new Context() await ctx.plugin(TypertRegistry) - ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle) + const rpc = carrier === 'web' + ? { call } + : { call, open } + const generation = new GenerationHarness() + ctx.provide('connection', { + rpc, + registerGenerationSource: generation.register, + start: () => ({ stop: () => {} }), + } as unknown as ConnectionHandle) const client = ctx.plugin({ inject, apply }) await client - return { ctx, client } + return { ctx, client, generation } } +async function *unexpectedInProcessStream(): AsyncGenerator { + throw new Error('fixture did not install an in-process stream') +} + +interface GenerationRun { + readonly signal: AbortSignal + readonly ready: Promise + readonly done: Promise + abort(reason?: unknown): void +} + +class GenerationHarness { + private source: ConnectionGenerationSource | undefined + private active: AbortController | undefined + + readonly register = (source: ConnectionGenerationSource): (() => void) => { + if (this.source !== undefined) throw new Error('fixture generation source already registered') + this.source = source + return () => { + if (this.source !== source) return + this.source = undefined + this.active?.abort(new Error('fixture generation source removed')) + this.active = undefined + } + } + + start(): GenerationRun { + if (this.source === undefined) throw new Error('fixture generation source is not registered') + if (this.active !== undefined) throw new Error('fixture generation is already active') + const source = this.source + const controller = new AbortController() + this.active = controller + let reportReady!: () => void + const ready = new Promise((resolve) => { reportReady = resolve }) + const done = Promise.resolve() + .then(() => source(controller.signal, reportReady)) + .finally(() => { + if (this.active === controller) this.active = undefined + }) + void done.catch(() => undefined) + return { + signal: controller.signal, + ready, + done, + abort: (reason) => { controller.abort(reason) }, + } + } + + startOverlapping(): GenerationRun { + if (this.source === undefined) throw new Error('fixture generation source is not registered') + const controller = new AbortController() + let reportReady!: () => void + const ready = new Promise((resolve) => { reportReady = resolve }) + const done = Promise.resolve().then(() => this.source?.(controller.signal, reportReady)) + .then(() => undefined) + void done.catch(() => undefined) + return { + signal: controller.signal, + ready, + done, + abort: (reason) => { controller.abort(reason) }, + } + } +} + +function deferredReadiness(): { + readonly promise: Promise + readonly resolve: () => void + readonly reject: (error: unknown) => void +} { + let resolve!: () => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + +async function loaderReadinessBench(readiness: Promise): Promise<{ + readonly client: Fiber + readonly start: ReturnType> + readonly stop: ReturnType void>> +}> { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const generation = new GenerationHarness() + const stop = vi.fn<() => void>() + const start = vi.fn(() => ({ stop })) + ctx.provide('connection', { + rpc: { + call: vi.fn(), + open: () => unexpectedInProcessStream(), + }, + registerGenerationSource: generation.register, + start, + } as unknown as ConnectionHandle) + ctx.provide('loader', { await: () => readiness }) + const client = ctx.plugin({ inject, apply }) + await client + return { client, start, stop } +} + +type EventStreamItem = + | { readonly kind: 'frame'; readonly value: unknown } + | { readonly kind: 'end' } + | { readonly kind: 'fail'; readonly error: unknown } + +interface EventStreamConnection { + readonly items: EventStreamItem[] + wake: (() => void) | undefined +} + +class RemoteEventCarrier { + readonly calls: { + readonly channel: string + readonly endpoint: string + readonly payload: unknown + readonly signal: AbortSignal + }[] = [] + private readonly connections = new Set() + private nextClient = 1 + + get activeConnections(): number { + return this.connections.size + } + + readonly open: NonNullable = (channel, endpoint, payload, signal) => { + this.calls.push({ channel, endpoint, payload, signal }) + return this.iterate(signal) + } + + emit(value: unknown): void { + this.feed({ kind: 'frame', value }) + } + + end(): void { + this.feed({ kind: 'end' }) + } + + fail(error: unknown): void { + this.feed({ kind: 'fail', error }) + } + + private feed(item: EventStreamItem): void { + for (const connection of this.connections) { + connection.items.push(item) + connection.wake?.() + } + } + + private async *iterate(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const clientId = `event-client-${String(this.nextClient++)}` + const connection: EventStreamConnection = { items: [], wake: undefined } + this.connections.add(connection) + const abort = (): void => { connection.wake?.() } + signal.addEventListener('abort', abort, { once: true }) + try { + yield { type: 'ready', clientId } + while (!signal.aborted) { + while (connection.items.length > 0) { + const item = connection.items.shift() as EventStreamItem + if (item.kind === 'end') return + if (item.kind === 'fail') throw item.error + yield item.value + } + if (signal.aborted) return + await new Promise((resolve) => { connection.wake = resolve }) + connection.wake = undefined + } + } finally { + signal.removeEventListener('abort', abort) + this.connections.delete(connection) + } + } +} + +async function eventBench( + call: ConnectionHandle['rpc']['call'] = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }), +): Promise<{ + readonly ctx: Context + readonly client: Fiber + readonly carrier: RemoteEventCarrier + readonly generation: GenerationHarness + readonly run: GenerationRun + readonly call: ConnectionHandle['rpc']['call'] +}> { + const carrier = new RemoteEventCarrier() + const { ctx, client, generation } = await benchFiber( + call, + 'in-process', + carrier.open, + ) + const run = generation.start() + await run.ready + return { ctx, client, carrier, generation, run, call } +} + +function approvalFrame(eventId: string, agentId: string, prompt: string): object { + return { + type: 'waterfall', + event: 'fixture/approval', + eventId, + agentId, + request: { prompt }, + } +} + +describe('Client Remote transport readiness', () => { + it('creates logical stream supervisors against the installed Connection', async () => { + const { ctx, client } = await benchFiber(vi.fn()) + const stream = ctx.remote.$stream({ + name: 'fixture stream', + open: () => unexpectedInProcessStream(), + ended: () => new Error('fixture stream ended'), + }) + + expect(stream).toBeInstanceOf(RemoteStream) + await stream.dispose() + await client.dispose() + }) + + it('starts after Loader settlement and stops the owned loop on disposal', async () => { + const readiness = deferredReadiness() + const { client, start, stop } = await loaderReadinessBench(readiness.promise) + expect(start).not.toHaveBeenCalled() + + readiness.resolve() + await vi.waitFor(() => { expect(start).toHaveBeenCalledTimes(1) }) + + await client.dispose() + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('does not start when disposal wins the Loader-settlement race', async () => { + const readiness = deferredReadiness() + const { client, start, stop } = await loaderReadinessBench(readiness.promise) + + await client.dispose() + readiness.resolve() + await Promise.resolve() + + expect(start).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() + }) + + it('leaves the transport stopped when Loader settlement rejects', async () => { + const readiness = deferredReadiness() + const { client, start, stop } = await loaderReadinessBench(readiness.promise) + + readiness.reject(new Error('fixture Loader failed')) + await Promise.resolve() + await Promise.resolve() + + expect(start).not.toHaveBeenCalled() + await client.dispose() + expect(stop).not.toHaveBeenCalled() + }) +}) + describe('Client Typert API', () => { - it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => { + it('mounts concrete direct methods, validates inputs, and withdraws retained handles', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) @@ -210,12 +625,8 @@ describe('Client Typert API', () => { call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ - ok: false, - error: { - code: 'internal', - message: 'client api: probe/create failed: client api: probe/create rejected "result"', - details: {}, - }, + ok: true, + value: { ref: 1 }, }) await assembly.dispose() @@ -271,6 +682,7 @@ describe('Client Typert API', () => { const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + resolve: id => id === 'agent-2' ? agentCtx : undefined, }) const assembly = ctx.plugin(Object.assign( (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }), @@ -301,6 +713,7 @@ describe('Client Typert API', () => { const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + resolve: id => id === 'agent-2' ? agentCtx : undefined, }) const assembly = ctx.plugin(Object.assign( (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }), @@ -323,15 +736,15 @@ describe('Client Typert API', () => { expect(ctx.get('remote.probe')).toBeUndefined() }) - it('rejects weak descriptors and namespace collisions before registration', async () => { + it('accepts weak result codecs and rejects namespace collisions before registration', async () => { const ctx = await bench(vi.fn()) const weak: InvocationDescriptor = { ...directDescriptor(), result: { mode: 'src-json' }, } - await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] })) - .rejects.toThrow('has no strict codec') + const disposeWeak = await ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] }) + await disposeWeak() await expect(ctx.remote.$mount({ package: '@fixture/conflict', descriptors: [{ ...directDescriptor(), namespace: '$mount' }], @@ -346,6 +759,7 @@ describe('Client Typert API', () => { const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + resolve: id => id === 'agent-remounted' ? agentCtx : undefined, }) const direct = directDescriptor() const context = contextDescriptor() @@ -432,6 +846,42 @@ describe('Client Typert API', () => { await retry() }) + it('rolls back earlier namespaces when a later namespace fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/archive#archive/store', + namespace: 'archive', + method: 'store', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'store') throw new Error('fixture later-namespace failure') + return defineProperty(target, key, attributes) + }) + try { + await expect(ctx.remote.$mount({ + package: '@fixture/failing-namespaces', + descriptors: [first, second], + })).rejects.toThrow('fixture later-namespace failure') + } finally { + spy.mockRestore() + } + + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + expect((ctx.remote as unknown as Record).archive).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + + const retry = await ctx.remote.$mount({ + package: '@fixture/retry-namespaces', + descriptors: [first, second], + }) + expect(ctx.remote.probe.create).toBeTypeOf('function') + expect((ctx.remote as unknown as Record>).archive?.store).toBeTypeOf('function') + await retry() + }) + it('rolls back a direct projection when its scoped projection fails to install', async () => { const ctx = await bench(vi.fn()) const disposeContext = await ctx.remote.$mount({ @@ -579,7 +1029,7 @@ describe('Client Typert API', () => { })).rejects.toThrow('scope must select its only lookup parameter') }) - it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { + it('validates invocation arity, required adapters, live Connection, and mutable descriptor codecs', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) @@ -599,7 +1049,7 @@ describe('Client Typert API', () => { await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' })) .rejects.toThrow('expected 2 business argument(s)') await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' })) - .rejects.toThrow('no Client Context binder') + .rejects.toThrow('no Client Context adapter') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') @@ -640,6 +1090,28 @@ describe('Client Typert API', () => { expect((ctx.remote as unknown as Record).probe).toBeUndefined() }) + it('keeps a namespace while another contribution still owns a method', async () => { + const ctx = await bench(vi.fn()) + const disposeCreate = await ctx.remote.$mount({ + package: '@fixture/create-contribution', + descriptors: [directDescriptor()], + }) + const disposeMaybe = await ctx.remote.$mount({ + package: '@fixture/maybe-contribution', + descriptors: [maybeDescriptor()], + }) + const namespace = ctx.get('remote.probe') as unknown as Record + + await disposeCreate() + + expect(ctx.get('remote.probe') !== undefined).toBe(true) + expect(namespace.create).toBeUndefined() + expect(namespace.maybe).toBeTypeOf('function') + + await disposeMaybe() + expect(ctx.get('remote.probe')).toBeUndefined() + }) + it('fails a method obtained from a withdrawn namespace getter', async () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) @@ -804,7 +1276,7 @@ describe('Client Typert API', () => { }) it('owns each $on subscription in the calling fiber', async () => { - const { ctx, client } = await benchFiber(vi.fn()) + const { ctx, client, carrier } = await eventBench() const seen: string[] = [] const subscriber = ctx.plugin(Object.assign( (scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) }, @@ -812,103 +1284,1098 @@ describe('Client Typert API', () => { )) await subscriber - ctx.remote.$dispatch('fixture/changed', ['settings']) - expect(seen).toEqual(['settings']) + expect(carrier.calls).toEqual([expect.objectContaining({ + channel: '/api', endpoint: '$events', payload: { args: {} }, + })]) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['settings'] }) + await vi.waitFor(() => { expect(seen).toEqual(['settings']) }) await subscriber.dispose() - ctx.remote.$dispatch('fixture/changed', ['after fiber disposal']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['after fiber disposal'] }) + await Promise.resolve() expect(seen).toEqual(['settings']) await client.dispose() expect(ctx.get('remote')).toBeUndefined() }) - it('isolates a throwing listener from the rest of the same event', async () => { - const ctx = await bench(vi.fn()) + it('isolates throwing and rejected notification listeners', async () => { + const { ctx, client, carrier } = await eventBench() const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) const seen: string[] = [] - const disposeFirst = ctx.remote.$on('fixture/changed', () => { - throw new Error('fixture listener failure') - }) - ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) - try { - ctx.remote.$dispatch('fixture/changed', ['credentials']) - - expect(seen).toEqual(['credentials']) - expect(consoleError).toHaveBeenCalledWith( - 'client api: Remote event "fixture/changed" listener threw:', - expect.any(Error), - ) - disposeFirst() - ctx.remote.$dispatch('fixture/changed', ['commands']) - expect(seen).toEqual(['credentials', 'commands']) - expect(consoleError).toHaveBeenCalledTimes(1) - } finally { - consoleError.mockRestore() + const failingListener = (namespace: string): unknown => { + if (namespace === 'sync') throw new Error('fixture listener failure') + return Promise.reject(new Error('fixture async failure')) } - }) - - it('contains an async listener whose promise rejects', async () => { - const ctx = await bench(vi.fn()) - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const seen: string[] = [] - // The declared return is void, so nobody awaits an async listener: the - // rejection has to be contained here or it escapes as an unhandled one. - ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) // oxlint-disable-line typescript/no-misused-promises + const disposeThrowing = ctx.remote.$on('fixture/changed', failingListener) ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) try { - ctx.remote.$dispatch('fixture/changed', ['credentials']) - await Promise.resolve() - await Promise.resolve() + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['sync'] }) + await vi.waitFor(() => { expect(seen).toEqual(['sync']) }) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['async'] }) + await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async']) }) + expect(consoleError).toHaveBeenCalledTimes(2) - expect(seen).toEqual(['credentials']) - expect(consoleError).toHaveBeenCalledWith( - 'client api: Remote event "fixture/changed" listener threw:', - expect.any(Error), - ) + disposeThrowing() + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] }) + await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async', 'survivor']) }) } finally { consoleError.mockRestore() + await client.dispose() } }) it('retires only its own registration when one listener subscribes twice', async () => { - const ctx = await bench(vi.fn()) + const { ctx, client, carrier } = await eventBench() const seen: string[] = [] - // One function object, two registrations. A table keyed by listener identity - // stores it once, so the first frame would reach it once instead of twice - // and either disposer would silence both. const listener = (namespace: string): void => { seen.push(namespace) } const disposeFirst = ctx.remote.$on('fixture/changed', listener) ctx.remote.$on('fixture/changed', listener) - ctx.remote.$dispatch('fixture/changed', ['both']) - expect(seen).toEqual(['both', 'both']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['both'] }) + await vi.waitFor(() => { expect(seen).toEqual(['both', 'both']) }) - // The surviving registration keeps receiving after its twin retires. disposeFirst() - ctx.remote.$dispatch('fixture/changed', ['survivor']) - expect(seen).toEqual(['both', 'both', 'survivor']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] }) + await vi.waitFor(() => { expect(seen).toEqual(['both', 'both', 'survivor']) }) - // Disposing twice is inert: the record is already gone, so the second call - // must not splice the surviving twin out from under its own owner. disposeFirst() - ctx.remote.$dispatch('fixture/changed', ['still here']) - expect(seen).toEqual(['both', 'both', 'survivor', 'still here']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['still here'] }) + await vi.waitFor(() => { + expect(seen).toEqual(['both', 'both', 'survivor', 'still here']) + }) + await client.dispose() }) - it('separates the consumer verb from the carrier handoff', () => { + it('keeps the carrier handoff private', () => { expectTypeOf().toHaveProperty('$on') - // The carrier owning the frame sink calls this; a consumer subscribes instead. - expectTypeOf().toHaveProperty('$dispatch') + expectTypeOf<'$dispatch' extends keyof ClientRemote ? true : false>().toEqualTypeOf() }) - it('drops a forwarded event nobody subscribes to', async () => { - const ctx = await bench(vi.fn()) + it('drops an unobserved notification and accepts a null-prototype frame', async () => { + const { ctx, client, carrier } = await eventBench() const seen: string[] = [] ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) - ctx.remote.$dispatch('fixture/idle', [1]) + carrier.emit({ type: 'emit', event: 'fixture/idle', args: [1] }) + carrier.emit(Object.assign(Object.create(null) as Record, { + type: 'emit', + event: 'fixture/changed', + args: ['null prototype'], + })) + await vi.waitFor(() => { expect(seen).toEqual(['null prototype']) }) + await client.dispose() + }) + + it('delegates immediately when the Agent adapter or Context is unavailable', async () => { + const { ctx, client, carrier, call } = await eventBench() + carrier.emit(approvalFrame('event-no-adapter', 'agent-late', 'no adapter')) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + + const target = ctx.extend() + const resolve = vi.fn((id: unknown) => id === 'agent-found' ? target : undefined) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-found') : undefined, + resolve, + }) + carrier.emit(approvalFrame('event-missing-context', 'agent-missing', 'missing')) + carrier.emit(approvalFrame('event-no-listener', 'agent-found', 'delegate')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(3) }) + expect(resolve).toHaveBeenCalledTimes(2) + for (const eventId of ['event-no-adapter', 'event-missing-context', 'event-no-listener']) { + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { args: { clientId: 'event-client-1', eventId, outcome: { kind: 'next' } } }, + expect.any(AbortSignal), + ) + } + await client.dispose() + }) + + it('reports Agent Context resolution failures and delegates', async () => { + const { ctx, client, carrier, call } = await eventBench() + ctx.typert.contexts.registerClient('agent', { + identity: () => undefined, + resolve: () => { throw new Error('fixture Context lookup failed') }, + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + carrier.emit(approvalFrame('event-resolve-error', 'agent-error', 'resolve')) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(consoleError).toHaveBeenCalledWith( + 'client api: Remote event "fixture/approval" listener threw:', + expect.objectContaining({ message: 'fixture Context lookup failed' }), + ) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-resolve-error', + outcome: { kind: 'next' }, + }, + }, + expect.any(AbortSignal), + ) + } finally { + consoleError.mockRestore() + await client.dispose() + } + }) + + it('normalizes undefined waterfall results and rejects non-JSON results', async () => { + const { ctx, client, carrier, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-results') : undefined, + resolve: id => id === 'agent-results' ? target : undefined, + }) + target.remote.$on('fixture/approval', async request => request.prompt === 'undefined' + ? undefined as unknown as FixtureApprovalOutcome + : Symbol('not JSON') as unknown as FixtureApprovalOutcome) + + carrier.emit(approvalFrame('event-undefined', 'agent-results', 'undefined')) + carrier.emit(approvalFrame('event-invalid', 'agent-results', 'invalid')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(2) }) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { args: { clientId: 'event-client-1', eventId: 'event-undefined', outcome: { kind: 'result' } } }, + expect.any(AbortSignal), + ) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-invalid', + outcome: { + kind: 'rejected', + error: { + name: 'TypeError', + message: 'Remote event listener result is not lossless JSON data', + }, + }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('fails the Connection generation when a result RPC is rejected', async () => { + const call = vi.fn().mockResolvedValue({ + ok: false, + error: { code: 'internal', message: 'fixture result rejected', details: {} }, + }) + const { client, carrier, run } = await eventBench(call) + + carrier.emit(approvalFrame('event-result-rejected', 'agent-missing', 'respond')) + + await expect(run.done).rejects.toThrow('fixture result rejected') + await client.dispose() + }) + + it('filters scoped waterfall listeners and returns the first claimed result', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, carrier } = await eventBench(call) + const target = ctx.extend({ + [Context.filter](candidate: Context): boolean { + const tag = (candidate as Context & { [fixtureContextTag]?: string })[fixtureContextTag] + return tag === undefined || tag === 'agent-1' + }, + }) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? target : undefined, + }) + const matching = ctx.extend({ [fixtureContextTag]: 'agent-1' }) + const excluded = ctx.extend({ [fixtureContextTag]: 'agent-2' }) + const seen: string[] = [] + ctx.remote.$on('fixture/approval', async function (request, next) { + expect(this).toBe(target) + expect(request.agent).toBe(target) + expect(request.signal).toBeInstanceOf(AbortSignal) + seen.push('root') + return next() + }) + matching.remote.$on('fixture/approval', async (_request, next) => { + seen.push('matching-next') + return next() + }) + excluded.remote.$on('fixture/approval', async () => { + seen.push('excluded') + return 'unavailable' + }) + matching.remote.$on('fixture/approval', async () => { + seen.push('matching-result') + return 'allowed' + }) + + carrier.emit(approvalFrame('event-1', 'agent-1', 'ship')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(seen).toEqual(['root', 'matching-next', 'matching-result']) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-1', + outcome: { kind: 'result', value: 'allowed' }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('returns a scoped listener rejection to the Host', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, carrier } = await eventBench(call) + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-rejected') : undefined, + resolve: id => id === 'agent-rejected' ? target : undefined, + }) + const rejection = Object.assign(new Error('the user cancelled ask_user_question'), { + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }) + target.remote.$on('fixture/approval', () => Promise.reject(rejection)) + + carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'cancelled')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-rejected', + outcome: { + kind: 'rejected', + error: { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }, + }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('returns Context-filter failures as rejections', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, carrier } = await eventBench(call) + const target = ctx.extend({ + [Context.filter](): boolean { + throw new Error('fixture Context filter failed') + }, + }) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-filter-failure') : undefined, + resolve: id => id === 'agent-filter-failure' ? target : undefined, + }) + ctx.remote.$on('fixture/approval', async (_request, next) => next()) + + carrier.emit(approvalFrame('event-filter-failure', 'agent-filter-failure', 'filter')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-filter-failure', + outcome: { + kind: 'rejected', + error: { + name: 'Error', + message: 'fixture Context filter failed', + }, + }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('cancels a pending Client listener without returning a late result', async () => { + const { ctx, client, carrier, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-cancel') : undefined, + resolve: id => id === 'agent-cancel' ? target : undefined, + }) + const entered = Promise.withResolvers() + target.remote.$on('fixture/approval', async (request) => { + const signal = request.signal as AbortSignal + entered.resolve(signal) + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return 'allowed' + }) + carrier.emit(approvalFrame('event-cancel', 'agent-cancel', 'wait')) + const deliverySignal = await entered.promise + + carrier.emit({ type: 'cancel', eventId: 'event-cancel' }) + await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) }) + await Promise.resolve() + expect(call).not.toHaveBeenCalled() + + await client.dispose() + }) + + it('drops a settled listener result when cancellation wins before reply', async () => { + const { ctx, client, carrier, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-cancel-race') : undefined, + resolve: id => id === 'agent-cancel-race' ? target : undefined, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + target.remote.$on('fixture/approval', async (request) => { + entered.resolve(request.signal as AbortSignal) + await release.promise + return 'allowed' + }) + carrier.emit(approvalFrame('event-cancel-race', 'agent-cancel-race', 'wait')) + const deliverySignal = await entered.promise + + release.resolve(undefined) + carrier.emit({ type: 'cancel', eventId: 'event-cancel-race' }) + await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) }) + await Promise.resolve() + expect(call).not.toHaveBeenCalled() + + await client.dispose() + }) + + it('cancels pending listener work when the generation ends', async () => { + const { ctx, client, carrier, run, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-generation') : undefined, + resolve: id => id === 'agent-generation' ? target : undefined, + }) + const entered = Promise.withResolvers() + target.remote.$on('fixture/approval', async (request) => { + const signal = request.signal as AbortSignal + entered.resolve(signal) + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return 'allowed' + }) + carrier.emit(approvalFrame('event-generation', 'agent-generation', 'wait')) + const deliverySignal = await entered.promise + + run.abort(new Error('fixture generation ended')) + await expect(run.done).resolves.toBeUndefined() + expect(deliverySignal.aborted).toBe(true) + expect(call).not.toHaveBeenCalled() + + await client.dispose() + }) + + it('contains a result transport failure after the generation is cancelled', async () => { + const response = Promise.withResolvers() + const call = vi.fn(() => response.promise) + const { client, carrier, run } = await eventBench(call) + carrier.emit(approvalFrame('event-late-result', 'agent-missing', 'respond')) + await vi.waitFor(() => { expect(call).toHaveBeenCalledOnce() }) + + run.abort(new Error('fixture generation cancelled')) + response.reject(new Error('fixture late result failure')) + await expect(run.done).resolves.toBeUndefined() + + await client.dispose() + }) + + it('normalizes a non-Error result transport failure', async () => { + const call = vi.fn().mockRejectedValue('fixture transport failure') + const { client, carrier, run } = await eventBench(call) + + carrier.emit(approvalFrame('event-result-throw', 'agent-missing', 'respond')) + + await expect(run.done).rejects.toMatchObject({ + message: 'client api: Remote event result delivery failed', + cause: 'fixture transport failure', + }) + await client.dispose() + }) + + it('keeps the newer generation tracked when an overlapping generation settles', async () => { + const { client, generation, run } = await eventBench() + const overlapping = generation.startOverlapping() + await overlapping.ready + + run.abort(new Error('fixture older generation ended')) + await expect(run.done).resolves.toBeUndefined() + overlapping.abort(new Error('fixture newer generation ended')) + await expect(overlapping.done).resolves.toBeUndefined() + await client.dispose() + }) + + it('opens the forwarded-event stream on the browser Remote mux', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, generation } = await benchFiber(call, 'web') + const seen: string[] = [] + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-browser') : undefined, + resolve: id => id === 'agent-browser' ? target : undefined, + }) + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + target.remote.$on('fixture/approval', async function (request) { + expect(this).toBe(target) + expect(request.agent).toBe(this) + expect(request.signal).toBeInstanceOf(AbortSignal) + return 'allowed' + }) + const run = generation.start() + + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + expect(opened).toMatchObject({ + type: 'open', endpoint: '$events', payload: { args: {} }, + }) + socket.receive({ + type: 'item', + streamId: opened.streamId, + value: { type: 'ready', clientId: 'browser-client' }, + }) + await run.ready + socket.receive({ + type: 'item', + streamId: opened.streamId, + value: { type: 'emit', event: 'fixture/changed', args: ['browser'] }, + }) + await vi.waitFor(() => { expect(seen).toEqual(['browser']) }) + + socket.receive({ + type: 'item', + streamId: opened.streamId, + value: { + type: 'waterfall', + event: 'fixture/approval', + eventId: 'event-browser', + agentId: 'agent-browser', + request: { prompt: 'browser approval' }, + }, + }) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(socket.sent).toHaveLength(1) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'browser-client', + eventId: 'event-browser', + outcome: { kind: 'result', value: 'allowed' }, + }, + }, + expect.any(AbortSignal), + ) + + await client.dispose() + }) + }) + + it('publishes the Fixture Host description after Remote events report ready', async () => { + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { hostname: '127.0.0.1', search: '?fixture' }, + }) + const ctx = new Context() + try { + await ctx.plugin(TypertRegistry) + await ctx.plugin({ inject: [], apply: applyConnection }) + await ctx.plugin({ inject, apply }) + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error('fixture Connection service is unavailable') + + await vi.waitFor(() => { + expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture') + }) + } finally { + await ctx.fiber.dispose() + if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', locationDescriptor) + } + }) + + it.each([ + null, + [], + {}, + { type: 'pending' }, + { type: 'ready' }, + { type: 'ready', clientId: '' }, + { type: 'ready', clientId: 'client', extra: true }, + { type: 'emit', event: 'fixture/changed', args: ['too early'] }, + ])('rejects malformed forwarded-event readiness item %#', async (opening) => { + const open: NonNullable = () => (async function *() { + yield opening + })() + const { client, generation } = await benchFiber( + vi.fn(), + 'in-process', + open, + ) + const run = generation.start() + try { + await expect(run.done).rejects.toThrow('forwarded Remote event stream did not begin with ready') + } finally { + await client.dispose() + } + }) + + it('propagates physical carrier failure and opens events for the replacement generation', async () => { + const { ctx, client, carrier, generation, run } = await eventBench() + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + expect(carrier.calls).toHaveLength(1) + + carrier.fail(new RemoteStreamCarrierError('fixture generation lost')) + await expect(run.done).rejects.toThrow('fixture generation lost') + const replacement = generation.start() + await replacement.ready + await vi.waitFor(() => { expect(carrier.calls).toHaveLength(2) }) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['replacement'] }) + await vi.waitFor(() => { expect(seen).toEqual(['replacement']) }) + + await client.dispose() + }) + + it.each([ + { + name: 'Host failure', + stop: (carrier: RemoteEventCarrier) => { + carrier.fail(new RemoteStreamError('internal', 'fixture Host failed', {})) + }, + message: 'fixture Host failed', + }, + { + name: 'normal end', + stop: (carrier: RemoteEventCarrier) => { carrier.end() }, + message: 'forwarded Remote event stream ended unexpectedly', + }, + ])('fails the active generation after $name', async ({ stop, message }) => { + const { ctx, client, carrier, run } = await eventBench() + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + stop(carrier) + await expect(run.done).rejects.toThrow(message) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] }) + await Promise.resolve() + expect(carrier.calls).toHaveLength(1) expect(seen).toEqual([]) + await client.dispose() + }) + + it.each([ + 'not an object', + null, + [], + {}, + { type: 'unknown' }, + { type: 'emit', event: 'fixture/changed' }, + { type: 'emit', event: 'fixture/changed', args: [], extra: true }, + { type: 'emit', event: 1, args: [] }, + { type: 'emit', event: '', args: [] }, + { type: 'emit', event: 'fixture/changed', args: {} }, + { type: 'emit', event: 'fixture/changed', args: [1n] }, + { type: 'waterfall', event: 'fixture/approval', eventId: '', agentId: 'agent-1', request: {} }, + { type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: '', request: {} }, + { + type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { agent: null }, + }, + { + type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { signal: null }, + }, + { type: 'cancel', eventId: '' }, + { type: 'cancel', eventId: 'event-1', extra: true }, + ])('rejects malformed forwarded-event frame %# and stops that stream', async (frame) => { + const { ctx, client, carrier, run } = await eventBench() + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + carrier.emit(frame) + await expect(run.done).rejects.toThrow('client api: invalid forwarded Remote event frame') + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] }) + await Promise.resolve() + expect(carrier.calls).toHaveLength(1) + expect(seen).toEqual([]) + await client.dispose() + }) + + it('aborts and awaits forwarded-event delivery during disposal', async () => { + const { ctx, client, carrier, run } = await eventBench() + ctx.remote.$on('fixture/changed', () => {}) + expect(carrier.activeConnections).toBe(1) + const signal = carrier.calls[0]?.signal + + await client.dispose() + await expect(run.done).resolves.toBeUndefined() + + expect(signal?.aborted).toBe(true) + expect(carrier.activeConnections).toBe(0) + expect(ctx.get('remote')).toBeUndefined() + }) + + it('rejects a generation when its Connection has been withdrawn', async () => { + const carrier = new RemoteEventCarrier() + const { ctx, client, generation } = await benchFiber( + vi.fn(), + 'in-process', + carrier.open, + ) + ctx.set('connection', undefined) + const run = generation.start() + await expect(run.done).rejects.toThrow('$events has no active Connection') + expect(carrier.calls).toEqual([]) + await client.dispose() + }) + + it('guards stream iteration across mount and Connection withdrawal', async () => { + const call = vi.fn() + const ctx = await bench(call) + const firstDispose = await ctx.remote.$mount({ + package: '@fixture/stream-first', descriptors: [streamDescriptor()], + }) + const withdrawn = ctx.remote.probe.watch('withdrawn')[Symbol.asyncIterator]() + await firstDispose() + await expect(withdrawn.next()).rejects.toThrow('Remote method probe/watch is no longer mounted') + + const secondDispose = await ctx.remote.$mount({ + package: '@fixture/stream-second', descriptors: [streamDescriptor()], + }) + ctx.set('connection', undefined) + await expect(ctx.remote.probe.watch('offline')[Symbol.asyncIterator]().next()) + .rejects.toThrow('probe/watch has no active Connection') + + let release!: () => void + const released = new Promise((resolve) => { release = resolve }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + const source = async function *(): AsyncIterable { + markStarted() + await released + yield 'late item' + } + ctx.set('connection', { + rpc: { call, open: () => source() }, + } as unknown as ConnectionHandle) + const active = ctx.remote.probe.watch('active')[Symbol.asyncIterator]() + const pending = active.next() + await started + await secondDispose() + release() + await expect(pending).rejects.toThrow('Remote method probe/watch is no longer mounted') + }) + + it('publishes a namespace only after every contributed method is installed', async () => { + const ctx = await bench(vi.fn()) + let visible: string[] | undefined + const consumer = ctx.plugin({ + inject: ['remote.probe'], + apply(scope) { + const namespace = scope.get('remote.probe') as unknown as Record + visible = [typeof namespace.watch, typeof namespace.archive] + }, + }) + const archive: InvocationDescriptor = { + ...streamDescriptor(), + id: '@fixture/probe#probe/archive', + method: 'archive', + } + + const dispose = await ctx.remote.$mount({ + package: '@fixture/atomic-namespace', + descriptors: [streamDescriptor(), archive], + }) + await consumer.await() + + expect(visible).toEqual(['function', 'function']) + await dispose() + }) + + it('normalizes worker-local structural stream failures without sharing class identity', async () => { + const cases = [{ + failure: Object.assign(new Error('fixture Host rejected the stream'), { + dshRemoteStreamFailure: { + kind: 'remote' as const, + code: 'fixture-rejected', + details: { retry: false }, + }, + }), + assert: (error: unknown) => { + expect(error).toBeInstanceOf(RemoteStreamError) + expect(error).toMatchObject({ + code: 'fixture-rejected', + message: 'fixture Host rejected the stream', + details: { retry: false }, + }) + }, + }, { + failure: Object.assign(new Error('worker carrier stopped'), { + dshRemoteStreamFailure: { kind: 'carrier' as const }, + }), + assert: (error: unknown) => { + expect(error).toBeInstanceOf(RemoteStreamCarrierError) + expect(error).toMatchObject({ message: 'worker carrier stopped' }) + }, + }, { + failure: 'caller abort sentinel', + assert: (error: unknown) => { expect(error).toBe('caller abort sentinel') }, + }] + + for (const testCase of cases) { + const open: NonNullable = () => (async function *(): AsyncGenerator { + throw testCase.failure + })() + const { ctx, client } = await benchFiber( + vi.fn(), + 'in-process', + open, + ) + const dispose = await ctx.remote.$mount({ package: '@fixture/worker-stream', descriptors: [streamDescriptor()] }) + try { + const error = await ctx.remote.probe.watch('failure')[Symbol.asyncIterator]().next() + .then(() => undefined, (reason: unknown) => reason) + testCase.assert(error) + } finally { + await dispose() + await client.dispose() + } + } + }) + + it('multiplexes Remote streams without using the Connection RPC caller', async () => { + const originalWebSocket = globalThis.WebSocket + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { origin: 'https://harness.example' }, + }) + FakeWebSocket.sockets.length = 0 + const call = vi.fn() + const ctx = await bench(call, 'web') + expect(FakeWebSocket.sockets).toHaveLength(1) + const dispose = await ctx.remote.$mount({ package: '@fixture/stream', descriptors: [streamDescriptor()] }) + try { + const first = ctx.remote.probe.watch('alpha')[Symbol.asyncIterator]() + const firstItem = first.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + expect(socket.url).toBe('wss://harness.example/api/remote.mux') + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + expect(opened).toMatchObject({ + type: 'open', + endpoint: 'probe/watch', + payload: { args: { topic: 'alpha' } }, + }) + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:one' }) + await expect(firstItem).resolves.toEqual({ done: false, value: 'alpha:one' }) + const firstEnd = first.next() + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(firstEnd).resolves.toEqual({ done: true, value: undefined }) + + const failed = ctx.remote.probe.watch('failure')[Symbol.asyncIterator]() + const failedItem = failed.next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const failedOpen = JSON.parse(socket.sent[1]!) as { streamId: string } + socket.receive({ + type: 'error', + streamId: failedOpen.streamId, + error: { + code: 'lookup-unavailable', + message: 'fixture stream failed', + details: { lookup: 'missing' }, + }, + }) + await expect(failedItem).rejects.toMatchObject({ + name: 'RemoteStreamError', + code: 'lookup-unavailable', + message: 'fixture stream failed', + details: { lookup: 'missing' }, + }) + + const abort = new AbortController() + const cancelled = ctx.remote.probe.watch('cancel', abort.signal)[Symbol.asyncIterator]() + const cancelledItem = cancelled.next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) }) + const cancelledOpen = JSON.parse(socket.sent[2]!) as { streamId: string } + const cancellation = new Error('caller cancelled') + socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'already queued' }) + abort.abort(cancellation) + socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'after cancellation' }) + await expect(cancelledItem).rejects.toBe(cancellation) + await vi.waitFor(() => { + expect(socket.sent.map(text => JSON.parse(text) as unknown)).toContainEqual({ + type: 'cancel', streamId: cancelledOpen.streamId, + }) + }) + expect(call).not.toHaveBeenCalled() + } finally { + await dispose() + await ctx.fiber.dispose() + FakeWebSocket.sockets.length = 0 + FakeWebSocket.autoOpen = true + FakeWebSocket.dispatchClose = true + if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket + else globalThis.WebSocket = originalWebSocket + if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', locationDescriptor) + } }) }) + +describe('Remote stream client carrier lifecycle', () => { + it('connects without a logical stream, reconnects after failures, and stops permanently', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const client = new RemoteStreamMuxClient() + client.start() + client.start() + expect(FakeWebSocket.sockets).toHaveLength(1) + + const failed = FakeWebSocket.sockets[0]! + failed.fail() + await vi.advanceTimersByTimeAsync(500) + expect(FakeWebSocket.sockets).toHaveLength(2) + + const connected = FakeWebSocket.sockets[1]! + connected.open() + await vi.advanceTimersByTimeAsync(0) + expect(connected.sent).toEqual([]) + connected.fail() + await vi.advanceTimersByTimeAsync(500) + expect(FakeWebSocket.sockets).toHaveLength(3) + + const replacement = FakeWebSocket.sockets[2]! + replacement.open() + replacement.drop() + await vi.advanceTimersByTimeAsync(500) + expect(FakeWebSocket.sockets).toHaveLength(4) + + const final = FakeWebSocket.sockets[3]! + final.open() + await vi.advanceTimersByTimeAsync(0) + await client.close() + await client.close() + client.start() + await expect(client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client disposed') + await vi.advanceTimersByTimeAsync(20_000) + + expect(FakeWebSocket.sockets).toHaveLength(4) + expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' }) + expect(warn).toHaveBeenCalledTimes(3) + + const stopping = new RemoteStreamMuxClient() + stopping.start() + const racing = FakeWebSocket.sockets[4]! + racing.open() + racing.drop() + await stopping.close() + await vi.advanceTimersByTimeAsync(20_000) + expect(FakeWebSocket.sockets).toHaveLength(5) + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + }) + + it('shares an in-flight connection and uses the internal ws URL without a browser origin', async () => { + await withFakeWebSocket(undefined, async () => { + FakeWebSocket.autoOpen = false + const client = new RemoteStreamMuxClient() + const first = client.open('feed/follow', { label: 'first' }, new AbortController().signal) + [Symbol.asyncIterator]() + const second = client.open('feed/follow', { label: 'second' }, new AbortController().signal) + [Symbol.asyncIterator]() + const firstPending = first.next() + const secondPending = second.next() + expect(FakeWebSocket.sockets).toHaveLength(1) + const socket = FakeWebSocket.sockets[0]! + expect(socket.url).toBe('ws://dsh.internal/api/remote.mux') + + socket.open() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const streamIds = socket.sent.map(text => (JSON.parse(text) as { streamId: string }).streamId) + socket.receive({ type: 'end', streamId: streamIds[0] }) + socket.receive({ type: 'end', streamId: streamIds[1] }) + await expect(firstPending).resolves.toEqual({ done: true, value: undefined }) + await expect(secondPending).resolves.toEqual({ done: true, value: undefined }) + await client.close() + }) + }) + + it('keeps waiters across failed attempts and contains waiter cancellation', async () => { + await withFakeWebSocket('null', async () => { + FakeWebSocket.autoOpen = false + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const closedClient = new RemoteStreamMuxClient() + const closed = closedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + FakeWebSocket.sockets[0]!.drop() + await vi.advanceTimersByTimeAsync(500) + + const replacement = FakeWebSocket.sockets[1]! + replacement.open() + await vi.advanceTimersByTimeAsync(0) + const { streamId } = JSON.parse(replacement.sent[0]!) as { streamId: string } + replacement.receive({ type: 'end', streamId }) + await expect(closed).resolves.toEqual({ done: true, value: undefined }) + await closedClient.close() + + const disposedClient = new RemoteStreamMuxClient() + const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + FakeWebSocket.sockets[2]!.fail() + await disposedClient.close() + await expect(disposed).rejects.toThrow('Remote stream client disposed') + + const abortedClient = new RemoteStreamMuxClient() + const abort = new AbortController() + const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + abort.abort('cancelled while connecting') + await expect(aborted).rejects.toBe('cancelled while connecting') + await abortedClient.close() + expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux') + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + }) + + it('fails active streams on an invalid frame and ignores later frames', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + const stream = client.open('feed/follow', {}, new AbortController().signal)[Symbol.asyncIterator]() + const pending = stream.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + const { streamId } = JSON.parse(socket.sent[0]!) as { streamId: string } + FakeWebSocket.dispatchClose = false + socket.receiveRaw(new Uint8Array([1, 2, 3])) + socket.receive({ type: 'item', streamId, value: 'too late' }) + socket.drop() + + await expect(pending).rejects.toMatchObject({ + name: 'RemoteStreamCarrierError', message: 'api gateway: invalid Remote stream frame', + }) + expect(socket.closedWith).toContainEqual({ code: 4002, reason: 'invalid Remote stream frame' }) + await client.close() + }) + }) + + it('completes a stream and drops a frame racing with cancellation', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + const completed = client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]() + const completedPending = completed.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + const completedOpen = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'end', streamId: completedOpen.streamId }) + await expect(completedPending).resolves.toEqual({ done: true, value: undefined }) + + const abort = new AbortController() + const cancelled = client.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const cancelledOpen = JSON.parse(socket.sent[1]!) as { streamId: string } + const reason = new Error('fixture cancellation race') + abort.abort(reason) + socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'too late' }) + await expect(cancelled).rejects.toBe(reason) + await client.close() + }) + }) + + it('contains non-Error cancellation reasons and late socket close events', async () => { + await withFakeWebSocket('http://harness.example', async () => { + const cancelledClient = new RemoteStreamMuxClient() + const abort = new AbortController() + const cancelled = cancelledClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + abort.abort('caller cancelled') + await expect(cancelled).rejects.toThrow('caller cancelled') + await cancelledClient.close() + + FakeWebSocket.dispatchClose = false + const disposedClient = new RemoteStreamMuxClient() + const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[1]?.sent).toHaveLength(1) }) + const disposedSocket = FakeWebSocket.sockets[1]! + await disposedClient.close() + disposedSocket.receive({ type: 'end', streamId: 'stale' }) + disposedSocket.drop() + await expect(disposed).rejects.toThrow('Remote stream client disposed') + }) + }) +}) + +async function withFakeWebSocket( + origin: string | undefined, + run: () => Promise, +): Promise { + const originalWebSocket = globalThis.WebSocket + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + if (origin === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', { configurable: true, value: { origin } }) + FakeWebSocket.sockets.length = 0 + FakeWebSocket.autoOpen = true + FakeWebSocket.dispatchClose = true + try { + await run() + } finally { + FakeWebSocket.sockets.length = 0 + FakeWebSocket.autoOpen = true + FakeWebSocket.dispatchClose = true + if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket + else globalThis.WebSocket = originalWebSocket + if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', locationDescriptor) + } +} diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 8baa3d0b99..93651f12e8 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -735,7 +735,7 @@ describe('TypertGatewayService', () => { expect(service.calls).toEqual([]) }) - it('distinguishes strict input and result validation failures', async () => { + it('validates strict input without decoding the business result', async () => { const { ctx, service } = await setup() registerStrict(ctx, [strictOnlyDescriptor()]) @@ -746,27 +746,23 @@ describe('TypertGatewayService', () => { }), 'input-invalid') service.nextResult = { title: 1 } - await expectCode(ctx.typertGateway.invoke({ + await expect(ctx.typertGateway.invoke({ namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, - }), 'result-invalid') + })).resolves.toEqual({ title: 1 }) }) - it('rejects non-JSON values after strict codec validation', async () => { + it('does not inspect non-JSON business results', async () => { const { ctx, service } = await setup() - const descriptor = strictOnlyDescriptor() - registerStrict(ctx, [{ - ...descriptor, - result: strictCodec('@fixture/gateway#UnknownResult', z.unknown()), - }]) + registerStrict(ctx, [strictOnlyDescriptor()]) service.nextResult = 1n - await expectCode(ctx.typertGateway.invoke({ + await expect(ctx.typertGateway.invoke({ namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, - }), 'result-invalid') + })).resolves.toBe(1n) }) it.each([ @@ -801,7 +797,7 @@ describe('TypertGatewayService', () => { expect(service.calls).toContain('passthrough') }) - it('rejects cyclic SRC input and non-JSON SRC results', async () => { + it('rejects cyclic SRC input without inspecting SRC results', async () => { const { ctx, service } = await setup() const cyclic: { self?: unknown } = {} cyclic.self = cyclic @@ -811,12 +807,13 @@ describe('TypertGatewayService', () => { args: { value: cyclic }, }), 'input-invalid') - service.nextResult = new Date(0) - await expectCode(ctx.typertGateway.invoke({ + const result = new Date(0) + service.nextResult = result + await expect(ctx.typertGateway.invoke({ namespace: 'goals', method: 'passthrough', args: { value: null }, - }), 'result-invalid') + })).resolves.toBe(result) }) it('accepts dense JSON and rejects decorated arrays and object properties', async () => { @@ -1046,6 +1043,56 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('claims and validates in-process Remote event results for the active Client generation', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + await ctx.plugin(TypertGatewayService) + const connection = rawConnection(ctx) + const handler = connection.handler + if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') + expect(connection.matches?.('$events/result')).toBe(true) + + const result = { + args: { clientId: 'missing-client', eventId: 'missing', outcome: { kind: 'next' } }, + } + const inactive = await handler('$events/result', result, new AbortController().signal) + expect(inactive).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (inactive.ok) throw new Error('inactive Remote event result unexpectedly succeeded') + expect(inactive.error.message).toContain('identifies no active event stream') + + const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + })()) + const carrier = new AbortController() + const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) + const opening = await events.next() + expect(opening).toMatchObject({ done: false, value: { type: 'ready' } }) + if (opening.done) throw new Error('Remote event stream ended before ready') + const clientId: unknown = Reflect.get(opening.value as object, 'clientId') + if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id') + + for (const payload of [null, [], {}, { other: {} }]) { + const invalid = await handler('$events/result', payload, carrier.signal) + expect(invalid).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (invalid.ok) throw new Error('invalid Remote event result payload unexpectedly succeeded') + expect(invalid.error.message).toContain('requires exactly one plain-object args field') + } + await expect(handler('$events/result', { + args: { clientId, eventId: 'missing', outcome: { kind: 'next' } }, + }, carrier.signal)).resolves.toEqual({ + ok: true, + value: undefined, + }) + + await events.return(undefined) + await unregister() + await ctx.fiber.dispose() + }) + it('preserves a lookup policy rejection through the Connection RPC result', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) @@ -1228,6 +1275,17 @@ function rawConnection(ctx: Context): FakeConnectionService { return receiver[symbols.original] ?? receiver } +interface GatewayEventHarness { + openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator +} + +function rawGatewayEventHarness(ctx: Context): GatewayEventHarness { + const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & { + [symbols.original]?: GatewayEventHarness + } + return receiver[symbols.original] ?? receiver +} + function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { return ctx.typert.register({ package: '@fixture/gateway', @@ -1256,6 +1314,7 @@ function contextProvider(context: Context) { return { wire: 'agentId', wireTypeSymbol: '@fixture/domain#AgentId', + identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined, resolve: (id: string) => id === 'agent-1' ? context : undefined, } } diff --git a/packages/api/gateway/tests/journal-stream.client.spec.ts b/packages/api/gateway/tests/journal-stream.client.spec.ts new file mode 100644 index 0000000000..38362c5598 --- /dev/null +++ b/packages/api/gateway/tests/journal-stream.client.spec.ts @@ -0,0 +1,785 @@ +import { describe, expect, it, vi } from 'vitest' +import { + RemoteJournalStream, + RemoteStream, + RemoteStreamCarrierError, + type RemoteJournalChange, + type RemoteJournalFrame, + type RemoteStreamFactory, + type RemoteStreamItem, + type RemoteStreamOptions, +} from '../src/client/index.ts' + +interface Entry { + readonly seq: number +} + +interface Page { + readonly entries: readonly Entry[] + readonly hasMore: boolean + readonly marker: string +} + +interface PageRequest { + readonly before?: number + readonly limit?: number +} + +interface Generation { + readonly frames: readonly ( + RemoteJournalFrame | Promise> + )[] + readonly terminal?: Error + readonly hold?: boolean + readonly waitAfterFrames?: Promise + readonly afterFrame?: (index: number) => void +} + +type PageSource = Page | Promise | ((signal: AbortSignal) => Promise) + +const AVAILABLE_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + +const entries = (...seqs: number[]): Entry[] => seqs.map(seq => ({ seq })) + +const page = (marker: string, seqs: number[], hasMore = false): Page => ({ + entries: entries(...seqs), + hasMore, + marker, +}) + +const STREAM_FACTORY = { + $stream(options: RemoteStreamOptions): RemoteStream { + return new RemoteStream(AVAILABLE_CONNECTION, options) + }, +} + +class FixtureJournal extends RemoteJournalStream { + constructor( + private readonly generations: Generation[], + private readonly pages: PageSource[], + private readonly calls: string[], + private readonly pageRequests: PageRequest[], + private readonly pageCursors: number[], + private readonly followCursors: (number | undefined)[], + changes: RemoteJournalChange[], + failed: (error: unknown) => void, + factory: RemoteStreamFactory = STREAM_FACTORY, + ) { + super(factory, { + name: 'fixture journal', + emptyCursor: -1, + entries: value => value.entries, + hasMore: value => value.hasMore, + cursor: entry => entry.seq, + compare: (left, right) => left - right, + follows: (left, right) => right === left + 1, + publish: (change) => { changes.push(change) }, + failed, + }) + } + + /** @inheritdoc */ + protected override async * follow( + after: number | undefined, + signal: AbortSignal, + ): AsyncIterable> { + this.calls.push('follow') + this.followCursors.push(after) + const generation = this.generations.shift() + if (generation === undefined) throw new Error('no scripted journal generation') + for (const [index, frame] of generation.frames.entries()) { + yield await frame + generation.afterFrame?.(index) + } + await generation.waitAfterFrames + if (generation.terminal !== undefined) throw generation.terminal + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + } + + /** @inheritdoc */ + protected override readPage( + request: PageRequest, + through: number, + signal: AbortSignal, + ): Promise { + this.calls.push('page') + this.pageRequests.push(request) + this.pageCursors.push(through) + const value = this.pages.shift() + if (value === undefined) throw new Error('no scripted journal page') + return typeof value === 'function' ? value(signal) : Promise.resolve(value) + } + + /** @inheritdoc */ + protected override repairRequest(request: PageRequest): PageRequest { + return request.limit === undefined ? {} : { limit: request.limit } + } +} + +function journalFixture( + generations: Generation[], + pages: PageSource[], + factory: RemoteStreamFactory = STREAM_FACTORY, +): { + readonly journal: RemoteJournalStream + readonly changes: RemoteJournalChange[] + readonly failed: ReturnType + readonly calls: string[] + readonly pageRequests: PageRequest[] + readonly pageCursors: number[] + readonly followCursors: (number | undefined)[] +} { + const calls: string[] = [] + const pageRequests: PageRequest[] = [] + const pageCursors: number[] = [] + const followCursors: (number | undefined)[] = [] + const changes: RemoteJournalChange[] = [] + const failed = vi.fn() + const journal = new FixtureJournal( + generations, + pages, + calls, + pageRequests, + pageCursors, + followCursors, + changes, + failed, + factory, + ) + return { journal, changes, failed, calls, pageRequests, pageCursors, followCursors } +} + +function remoteItem( + generation: number, + value: RemoteJournalFrame, + signal: AbortSignal, +): RemoteStreamItem> { + return { generation, value, signal, accept: vi.fn() } +} + +function controlledFactory( + next: () => Promise>>>, +): RemoteStreamFactory { + const lifetime = new AbortController() + return { + $stream(): RemoteStream { + const iterator = { + next, + return: async () => ({ done: true as const, value: undefined }), + } + return { + signal: lifetime.signal, + restart: () => {}, + dispose: async () => { lifetime.abort() }, + [Symbol.asyncIterator]: () => iterator, + } as unknown as RemoteStream + }, + } +} + +describe('RemoteJournalStream', () => { + it('opens follow before page, removes overlap, appends live entries, and prepends history', async () => { + const fixture = journalFixture( + [{ + frames: [ + { type: 'opened', cursor: 3 }, + { type: 'entry', entry: { seq: 3 } }, + { type: 'entry', entry: { seq: 4 } }, + ], + hold: true, + }], + [page('tail', [2, 3], true), page('older', [0, 1])], + ) + + await fixture.journal.open({ limit: 2 }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + await fixture.journal.prepend({ before: 2, limit: 2 }) + + expect(fixture.calls.slice(0, 2)).toEqual(['follow', 'page']) + expect(fixture.pageRequests).toEqual([{ limit: 2 }, { before: 2, limit: 2 }]) + expect(fixture.pageCursors).toEqual([3, 4]) + expect(fixture.changes).toEqual([ + { type: 'replace', page: page('tail', [2, 3], true), entries: entries(2, 3), hasMore: true }, + { type: 'append', entry: { seq: 4 } }, + { type: 'prepend', page: page('older', [0, 1]), entries: entries(0, 1), hasMore: false }, + ]) + await fixture.journal.dispose() + await fixture.journal.dispose() + }) + + it('exposes its shared cancellation signal', async () => { + const fixture = journalFixture( + [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }], + [page('empty', [])], + ) + + expect(fixture.journal.signal.aborted).toBe(false) + await fixture.journal.open({}) + await fixture.journal.dispose() + expect(fixture.journal.signal.aborted).toBe(true) + }) + + it('classifies normal endings before initial and resumed opening cursors', async () => { + const initial = journalFixture([{ frames: [] }], []) + await expect(initial.journal.open({})).rejects.toThrow( + 'fixture journal ended before its opening cursor', + ) + + const finish = Promise.withResolvers() + const resumed = journalFixture( + [ + { frames: [{ type: 'opened', cursor: 0 }], waitAfterFrames: finish.promise }, + { frames: [] }, + ], + [page('initial', [0])], + ) + await resumed.journal.open({}) + finish.resolve(undefined) + await vi.waitFor(() => { expect(resumed.failed).toHaveBeenCalledOnce() }) + expect(resumed.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'resumed fixture journal ended before its opening cursor', + }) + await resumed.journal.dispose() + }) + + it('prepends into an empty window and accepts its first live entry', async () => { + const empty = journalFixture( + [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }], + [page('empty', []), page('older', [0]), page('oldest', [])], + ) + await empty.journal.open({}) + await empty.journal.prepend({}) + expect(empty.changes.at(-1)).toEqual({ + type: 'prepend', page: page('older', [0]), entries: entries(0), hasMore: false, + }) + await empty.journal.prepend({}) + expect(empty.changes.at(-1)).toEqual({ + type: 'prepend', page: page('oldest', []), entries: [], hasMore: false, + }) + await empty.journal.dispose() + + const live = Promise.withResolvers>() + const followed = journalFixture( + [{ frames: [{ type: 'opened', cursor: -1 }, live.promise], hold: true }], + [page('empty', [])], + ) + await followed.journal.open({}) + live.resolve({ type: 'entry', entry: { seq: 0 } }) + await vi.waitFor(() => { expect(followed.changes).toHaveLength(2) }) + expect(followed.changes.at(-1)).toEqual({ type: 'append', entry: { seq: 0 } }) + await followed.journal.dispose() + }) + + it('publishes one sorted replacement from an exact page and live entries queued while it loads', async () => { + let resolvePage!: (value: Page) => void + const openingPage = new Promise((resolve) => { resolvePage = resolve }) + const fixture = journalFixture( + [{ + frames: [ + { type: 'opened', cursor: 15 }, + { type: 'entry', entry: { seq: 17 } }, + { type: 'entry', entry: { seq: 16 } }, + ], + hold: true, + }], + [openingPage], + ) + + const opening = fixture.journal.open({ limit: 6 }) + await vi.waitFor(() => { + expect(fixture.calls.filter(call => call === 'page')).toHaveLength(1) + }) + expect(fixture.changes).toEqual([]) + + resolvePage(page('opening', [10, 11, 12, 13, 14, 15])) + await opening + + expect(fixture.changes).toEqual([{ + type: 'replace', + page: page('opening', [10, 11, 12, 13, 14, 15]), + entries: entries(10, 11, 12, 13, 14, 15, 16, 17), + hasMore: false, + }]) + expect(fixture.pageCursors).toEqual([15]) + await fixture.journal.dispose() + }) + + it('repairs a replacement generation through one tail page and drops replay overlap', async () => { + const lost = new RemoteStreamCarrierError('carrier lost') + const fixture = journalFixture( + [ + { + frames: [ + { type: 'opened', cursor: 1 }, + { type: 'entry', entry: { seq: 2 } }, + ], + terminal: lost, + }, + { + frames: [ + { type: 'opened', cursor: 4 }, + { type: 'entry', entry: { seq: 3 } }, + { type: 'entry', entry: { seq: 4 } }, + ], + hold: true, + }, + ], + [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])], + ) + + await fixture.journal.open({ limit: 5 }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(3) }) + + expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) + expect(fixture.changes[2]).toMatchObject({ + type: 'replace', page: { marker: 'repair' }, entries: entries(0, 1, 2, 3, 4), + }) + expect(fixture.followCursors).toEqual([undefined, 2]) + expect(fixture.pageCursors).toEqual([1, 4]) + expect(fixture.failed).not.toHaveBeenCalled() + await fixture.journal.dispose() + }) + + it('restarts a page aborted with its carrier generation', async () => { + const fixture = journalFixture( + [ + { + frames: [{ type: 'opened', cursor: 1 }], + terminal: new RemoteStreamCarrierError('carrier lost during page'), + }, + { + frames: [{ type: 'opened', cursor: 2 }], + hold: true, + }, + ], + [ + signal => new Promise((_resolve, reject) => { + const aborted = (): void => { reject(new Error('page aborted')) } + signal.addEventListener('abort', aborted, { once: true }) + if (signal.aborted) aborted() + }), + page('replacement', [0, 1, 2]), + ], + ) + + await fixture.journal.open({ limit: 3 }) + + expect(fixture.changes).toEqual([{ + type: 'replace', + page: page('replacement', [0, 1, 2]), + entries: entries(0, 1, 2), + hasMore: false, + }]) + expect(fixture.pageCursors).toEqual([1, 2]) + expect(fixture.followCursors).toEqual([undefined, 1]) + expect(fixture.failed).not.toHaveBeenCalled() + await fixture.journal.dispose() + }) + + it('repairs a live gap before publishing another change', async () => { + const fixture = journalFixture( + [{ + frames: [ + { type: 'opened', cursor: 1 }, + { type: 'entry', entry: { seq: 4 } }, + ], + hold: true, + }], + [page('initial', [0, 1]), page('repair', [0, 1, 2, 3, 4])], + ) + + await fixture.journal.open({}) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + + expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'replace']) + expect(fixture.changes[1]).toMatchObject({ page: { marker: 'repair' } }) + expect(fixture.pageCursors).toEqual([1, 4]) + await fixture.journal.dispose() + }) + + it('replaces a superseded live-gap repair with the next generation', async () => { + const gap = Promise.withResolvers>() + const fixture = journalFixture( + [ + { + frames: [{ type: 'opened', cursor: 1 }, gap.promise], + terminal: new RemoteStreamCarrierError('generation lost'), + }, + { frames: [{ type: 'opened', cursor: 4 }], hold: true }, + ], + [ + page('initial', [0, 1]), + () => new Promise(() => {}), + page('replacement', [0, 1, 2, 3, 4]), + ], + ) + + await fixture.journal.open({ limit: 5 }) + gap.resolve({ type: 'entry', entry: { seq: 4 } }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + expect(fixture.changes.at(-1)).toMatchObject({ + type: 'replace', page: { marker: 'replacement' }, entries: entries(0, 1, 2, 3, 4), + }) + await fixture.journal.dispose() + }) + + it('replaces a superseded second repair page with the next generation', async () => { + const live = Promise.withResolvers>() + const liveConsumed = Promise.withResolvers() + const openingPage = Promise.withResolvers() + const finish = Promise.withResolvers() + const fixture = journalFixture( + [ + { + frames: [{ type: 'opened', cursor: 1 }, live.promise], + waitAfterFrames: finish.promise, + terminal: new RemoteStreamCarrierError('generation lost'), + afterFrame: (index) => { if (index === 1) liveConsumed.resolve(undefined) }, + }, + { frames: [{ type: 'opened', cursor: 4 }], hold: true }, + ], + [ + openingPage.promise, + () => new Promise(() => {}), + page('replacement', [0, 1, 2, 3, 4]), + ], + ) + + const opening = fixture.journal.open({}) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1]) }) + live.resolve({ type: 'entry', entry: { seq: 3 } }) + await liveConsumed.promise + openingPage.resolve(page('opening', [0, 1])) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1, 3]) }) + finish.resolve(undefined) + await opening + + expect(fixture.pageCursors).toEqual([1, 3, 4]) + expect(fixture.changes).toEqual([{ + type: 'replace', + page: page('replacement', [0, 1, 2, 3, 4]), + entries: entries(0, 1, 2, 3, 4), + hasMore: false, + }]) + await fixture.journal.dispose() + }) + + it('rereads the tail when queued entries advance beyond the opening page', async () => { + const live = Promise.withResolvers>() + const liveConsumed = Promise.withResolvers() + const openingPage = Promise.withResolvers() + const fixture = journalFixture( + [{ + frames: [{ type: 'opened', cursor: 1 }, live.promise], + hold: true, + afterFrame: (index) => { if (index === 1) liveConsumed.resolve(undefined) }, + }], + [openingPage.promise, page('repair', [0, 1, 2, 3])], + ) + + const opening = fixture.journal.open({ limit: 4 }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1]) }) + live.resolve({ type: 'entry', entry: { seq: 3 } }) + await liveConsumed.promise + openingPage.resolve(page('opening', [0, 1])) + await opening + + expect(fixture.pageCursors).toEqual([1, 3]) + expect(fixture.changes).toEqual([{ + type: 'replace', page: page('repair', [0, 1, 2, 3]), entries: entries(0, 1, 2, 3), hasMore: false, + }]) + await fixture.journal.dispose() + }) + + it('rejects when queued entries advance beyond the second repair page', async () => { + const firstLive = Promise.withResolvers>() + const secondLive = Promise.withResolvers>() + const firstConsumed = Promise.withResolvers() + const secondConsumed = Promise.withResolvers() + const openingPage = Promise.withResolvers() + const repairPage = Promise.withResolvers() + const fixture = journalFixture( + [{ + frames: [{ type: 'opened', cursor: 1 }, firstLive.promise, secondLive.promise], + hold: true, + afterFrame: (index) => { + if (index === 1) firstConsumed.resolve(undefined) + if (index === 2) secondConsumed.resolve(undefined) + }, + }], + [openingPage.promise, repairPage.promise], + ) + + const opening = fixture.journal.open({}) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1]) }) + firstLive.resolve({ type: 'entry', entry: { seq: 3 } }) + await firstConsumed.promise + openingPage.resolve(page('opening', [0, 1])) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([1, 3]) }) + secondLive.resolve({ type: 'entry', entry: { seq: 5 } }) + await secondConsumed.promise + repairPage.resolve(page('repair', [0, 1, 2, 3])) + + await expect(opening).rejects.toThrow('page did not reach its opening cursor') + }) + + it('reports a resumed generation that emits an entry before its cursor', async () => { + const finish = Promise.withResolvers() + const fixture = journalFixture( + [ + { + frames: [{ type: 'opened', cursor: 0 }], + waitAfterFrames: finish.promise, + terminal: new RemoteStreamCarrierError('lost'), + }, + { frames: [{ type: 'entry', entry: { seq: 1 } }] }, + ], + [page('initial', [0])], + ) + + await fixture.journal.open({}) + finish.resolve(undefined) + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'resumed fixture journal emitted an entry before its opening cursor', + }) + await fixture.journal.dispose() + }) + + it('reports a duplicate opening cursor after the initial page is published', async () => { + const duplicate = Promise.withResolvers>() + const fixture = journalFixture( + [{ frames: [{ type: 'opened', cursor: 0 }, duplicate.promise], hold: true }], + [page('initial', [0])], + ) + + await fixture.journal.open({}) + duplicate.resolve({ type: 'opened', cursor: 0 }) + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'fixture journal emitted more than one opening cursor', + }) + await fixture.journal.dispose() + }) + + it('propagates follow failures and duplicate cursors while an opening page is pending', async () => { + const pendingPage = new Promise(() => {}) + const failedFollow = journalFixture( + [{ frames: [{ type: 'opened', cursor: 0 }], terminal: new Error('follow failed') }], + [pendingPage], + ) + await expect(failedFollow.journal.open({})).rejects.toThrow('follow failed') + + const duplicate = Promise.withResolvers>() + const duplicatePage = new Promise(() => {}) + const duplicateOpening = journalFixture( + [{ frames: [{ type: 'opened', cursor: 0 }, duplicate.promise] }], + [duplicatePage], + ) + const opening = duplicateOpening.journal.open({}) + await vi.waitFor(() => { expect(duplicateOpening.pageCursors).toEqual([0]) }) + duplicate.resolve({ type: 'opened', cursor: 0 }) + await expect(opening).rejects.toThrow('more than one opening cursor') + }) + + it('rejects an iterator that ends while its opening page is pending', async () => { + const generation = new AbortController() + const results = [ + Promise.resolve>>>({ + done: false, + value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal), + }), + Promise.resolve>>>({ + done: true, + value: undefined, + }), + ] + const fixture = journalFixture( + [], + [new Promise(() => {})], + controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), + ) + + await expect(fixture.journal.open({})).rejects.toThrow( + 'ended while reading its replacement page', + ) + }) + + it('rejects an iterator that ends before its opening cursor', async () => { + const factory = controlledFactory(() => Promise.resolve({ done: true, value: undefined })) + const fixture = journalFixture([], [], factory) + + await expect(fixture.journal.open({})).rejects.toThrow( + 'ended before its opening cursor', + ) + }) + + it('suppresses a consumer failure after disposal begins', async () => { + const generation = new AbortController() + const next = Promise.withResolvers>>>() + const results = [ + Promise.resolve>>>({ + done: false, + value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal), + }), + next.promise, + ] + const fixture = journalFixture( + [], + [page('initial', [0])], + controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), + ) + + await fixture.journal.open({}) + const closing = fixture.journal.dispose() + next.resolve({ + done: false, + value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal), + }) + await closing + expect(fixture.failed).not.toHaveBeenCalled() + }) + + it.each([ + { name: 'ends', final: { done: true as const, value: undefined }, message: 'ended while replacing' }, + { + name: 'emits another opening cursor', + final: undefined, + message: 'more than one opening cursor', + }, + ])('rejects when an aborted page generation $name', async ({ final, message }) => { + const generation = new AbortController() + const pending = Promise.withResolvers>>>() + const nextPending = Promise.withResolvers>>>() + const results = [ + Promise.resolve>>>({ + done: false, + value: remoteItem(1, { type: 'opened', cursor: 0 }, generation.signal), + }), + pending.promise, + nextPending.promise, + ] + const fixture = journalFixture( + [], + [signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('page aborted')) }, { once: true }) + })], + controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), + ) + + const opening = fixture.journal.open({}) + await vi.waitFor(() => { expect(results).toHaveLength(1) }) + generation.abort() + if (final === undefined) { + pending.resolve({ + done: false, + value: remoteItem(1, { type: 'entry', entry: { seq: 1 } }, generation.signal), + }) + await vi.waitFor(() => { expect(results).toHaveLength(0) }) + nextPending.resolve({ + done: false, + value: remoteItem(1, { type: 'opened', cursor: 1 }, generation.signal), + }) + } else { + pending.resolve(final) + } + await expect(opening).rejects.toThrow(message) + }) + + it('rejects malformed opening and page sequences', async () => { + const beforeOpening = journalFixture( + [{ frames: [{ type: 'entry', entry: { seq: 0 } }] }], + [page('unused', [])], + ) + await expect(beforeOpening.journal.open({})).rejects.toThrow('entry before its opening cursor') + + const discontinuousPage = journalFixture( + [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }], + [page('bad', [0, 2, 3])], + ) + await expect(discontinuousPage.journal.open({})).rejects.toThrow('page contains discontinuous entries') + + const shortPage = journalFixture( + [{ frames: [{ type: 'opened', cursor: 3 }], hold: true }], + [page('short', [0, 1])], + ) + await expect(shortPage.journal.open({})).rejects.toThrow('page did not end at its requested cursor') + + const longPage = journalFixture( + [{ frames: [{ type: 'opened', cursor: 1 }], hold: true }], + [page('long', [0, 1, 2])], + ) + await expect(longPage.journal.open({})).rejects.toThrow('page did not end at its requested cursor') + }) + + it('reports duplicate and regressed generation cursors as terminal failures', async () => { + const duplicate = journalFixture( + [{ + frames: [{ type: 'opened', cursor: 1 }, { type: 'opened', cursor: 1 }], + }], + [page('initial', [0, 1])], + ) + await duplicate.journal.open({}) + await vi.waitFor(() => { expect(duplicate.failed).toHaveBeenCalledOnce() }) + const duplicateFailure: unknown = duplicate.failed.mock.calls[0]?.[0] + expect(duplicateFailure).toBeInstanceOf(Error) + if (!(duplicateFailure instanceof Error)) throw new Error('expected duplicate-cursor failure') + expect(duplicateFailure.message).toContain('more than one opening cursor') + + const regressed = journalFixture( + [ + { + frames: [{ type: 'opened', cursor: 1 }, { type: 'entry', entry: { seq: 2 } }], + terminal: new RemoteStreamCarrierError('lost'), + }, + { frames: [{ type: 'opened', cursor: 1 }] }, + ], + [page('initial', [0, 1])], + ) + await regressed.journal.open({}) + await vi.waitFor(() => { expect(regressed.failed).toHaveBeenCalledOnce() }) + const regressedFailure: unknown = regressed.failed.mock.calls[0]?.[0] + expect(regressedFailure).toBeInstanceOf(Error) + if (!(regressedFailure instanceof Error)) throw new Error('expected regressed-cursor failure') + expect(regressedFailure.message).toContain('behind the last applied entry') + }) + + it('rejects a discontinuous older page after publishing the fail-soft pagination state', async () => { + const fixture = journalFixture( + [{ frames: [{ type: 'opened', cursor: 4 }], hold: true }], + [page('initial', [3, 4], true), page('older', [0, 1], true)], + ) + await fixture.journal.open({}) + + await expect(fixture.journal.prepend({ before: 3 })).rejects.toThrow('history page is discontinuous') + expect(fixture.changes.at(-1)).toEqual({ + type: 'prepend', page: page('older', [0, 1], true), entries: [], hasMore: false, + }) + await fixture.journal.dispose() + }) + + it('guards lifecycle operations before and after open', async () => { + const fixture = journalFixture( + [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }], + [page('empty', [])], + ) + + await expect(fixture.journal.prepend({})).rejects.toThrow('is not open') + await fixture.journal.open({}) + await expect(fixture.journal.open({})).rejects.toThrow('already opened') + fixture.journal.restart() + await fixture.journal.dispose() + await expect(fixture.journal.prepend({})).rejects.toThrow('is not open') + }) +}) diff --git a/packages/api/gateway/tests/remote-event-protocol.host.spec.ts b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts new file mode 100644 index 0000000000..09259ea43f --- /dev/null +++ b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest' +import { + isRemoteJsonValue, + parseRemoteEventResult, + parseRemoteStreamClientMessage, + projectRemoteEventRequest, + projectRemoteEventRejection, + restoreRemoteEventRejection, +} from '../src/stream-protocol.ts' + +describe('Remote Event result protocol', () => { + it('accepts delegation, values, and structured rejections', () => { + expect(parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' }, + })).toEqual({ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' } }) + expect(parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' }, + })).toEqual({ clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' } }) + expect(parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } }, + })).toEqual({ + clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } }, + }) + expect(parseRemoteEventResult({ + clientId: 'client-1', + eventId: 'event-minimal', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } }, + })).toEqual({ + clientId: 'client-1', + eventId: 'event-minimal', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } }, + }) + expect(parseRemoteEventResult({ + clientId: 'client-1', + eventId: 'event-4', + outcome: { + kind: 'rejected', + error: { + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }, + }, + })).toEqual({ + clientId: 'client-1', + eventId: 'event-4', + outcome: { + kind: 'rejected', + error: { + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }, + }, + }) + }) + + it.each([ + null, + [], + {}, + { clientId: '', eventId: 'event-1', outcome: { kind: 'next' } }, + { clientId: 'client-1', eventId: '', outcome: { kind: 'next' } }, + { clientId: 'client-1', eventId: 'event-1', outcome: null }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' }, extra: true }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next', value: null } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', extra: true } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', value: undefined } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'unknown' } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: null } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: '', message: 'bad' } } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: 'Error', message: 1 } } }, + { + clientId: 'client-1', + eventId: 'event-1', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', code: 1 } }, + }, + { + clientId: 'client-1', + eventId: 'event-1', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', details: 1n } }, + }, + { + clientId: 'client-1', + eventId: 'event-1', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', extra: true } }, + }, + ])('rejects an invalid result frame: %#', (value) => { + expect(() => parseRemoteEventResult(value)).toThrow('api gateway: invalid Remote event') + }) + + it('rejects symbol properties in rejection records', () => { + const error = { name: 'Error', message: 'bad', [Symbol('hidden')]: true } + expect(() => parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error }, + })).toThrow('api gateway: invalid Remote event rejection') + }) +}) + +describe('Remote Event request projection', () => { + it('removes only the direct Agent and signal fields', () => { + const agent = { kind: 'agent' } + const abort = new AbortController() + const nested = { agent, signal: 'payload' } + const projected = projectRemoteEventRequest({ + agent, + signal: abort.signal, + prompt: 'approve?', + nested, + }, agent) + + expect(projected).toEqual({ + request: { prompt: 'approve?', nested }, + signal: abort.signal, + }) + expect(Object.getPrototypeOf(projected.request)).toBeNull() + }) + + it('accepts a null-prototype request and an omitted signal', () => { + const agent = { kind: 'agent' } + const request = Object.assign(Object.create(null) as Record, { + agent, + accepted: true, + }) + expect(projectRemoteEventRequest(request, agent)).toEqual({ + request: { accepted: true }, + }) + }) + + it('requires the scoped Agent as a direct own field', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest(null, agent)) + .toThrow('must carry its scoped Agent directly') + expect(() => projectRemoteEventRequest({}, agent)) + .toThrow('must carry its scoped Agent directly') + expect(() => projectRemoteEventRequest({ agent: {} }, agent)) + .toThrow('must carry its scoped Agent directly') + expect(() => projectRemoteEventRequest(Object.create({ agent }), agent)) + .toThrow('must carry its scoped Agent directly') + }) + + it('rejects an invalid direct signal', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest({ agent, signal: 'abort' }, agent)) + .toThrow('request signal must be an AbortSignal') + }) + + it('rejects non-JSON payload fields', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest({ agent, value: 1n }, agent)) + .toThrow('request is not lossless JSON data') + + const cycle: Record = {} + cycle.self = cycle + expect(() => projectRemoteEventRequest({ agent, cycle }, agent)) + .toThrow('request is not lossless JSON data') + }) + + it('rejects symbol and non-enumerable payload fields', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest({ agent, [Symbol('hidden')]: true }, agent)) + .toThrow('request has a non-JSON property') + + const hidden = { agent } + Object.defineProperty(hidden, 'value', { value: true }) + expect(() => projectRemoteEventRequest(hidden, agent)) + .toThrow('request has a non-JSON property') + }) +}) + +describe('Remote Event rejection projection', () => { + it('preserves stable error fields in both directions', () => { + const reason = Object.assign(new Error('declined'), { + name: 'ApprovalError', + code: 'DECLINED', + details: { retryable: false }, + }) + expect(projectRemoteEventRejection(reason)).toEqual({ + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }) + + const restored = restoreRemoteEventRejection({ + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }) as Error & { code?: string; details?: unknown } + expect(restored).toMatchObject({ + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }) + }) + + it('normalizes arbitrary reasons and omits non-JSON optional fields', () => { + expect(projectRemoteEventRejection('offline')).toEqual({ + name: 'Error', message: 'offline', + }) + expect(projectRemoteEventRejection(undefined)).toEqual({ + name: 'Error', message: 'undefined', + }) + expect(projectRemoteEventRejection({ + name: 1, message: 2, code: 3, details: 1n, + })).toEqual({ + name: 'Error', message: '[object Object]', + }) + + const restored = restoreRemoteEventRejection({ name: 'Error', message: 'offline' }) + expect(restored).toMatchObject({ name: 'Error', message: 'offline' }) + expect(restored).not.toHaveProperty('code') + expect(restored).not.toHaveProperty('details') + }) +}) + +describe('Remote Event JSON values', () => { + it('accepts lossless JSON values, null-prototype objects, and repeated references', () => { + const shared = { value: 1 } + const nullPrototype = Object.assign(Object.create(null) as Record, { + enabled: true, + }) + expect(isRemoteJsonValue({ + null: null, + string: 'value', + boolean: true, + number: 1.5, + array: [shared, shared], + nullPrototype, + })).toBe(true) + }) + + it.each([ + undefined, + 1n, + Symbol('value'), + () => undefined, + NaN, + Number.POSITIVE_INFINITY, + -0, + ])('rejects a non-lossless scalar: %s', (value) => { + expect(isRemoteJsonValue(value)).toBe(false) + }) + + it('rejects cycles and non-plain arrays and objects', () => { + const cycle: Record = {} + cycle.self = cycle + expect(isRemoteJsonValue(cycle)).toBe(false) + + class Fixture { + value = 1 + } + expect(isRemoteJsonValue(new Fixture())).toBe(false) + + const customArray = [1] + Object.setPrototypeOf(customArray, null) + expect(isRemoteJsonValue(customArray)).toBe(false) + expect(isRemoteJsonValue(Object.assign([1], { extra: true }))).toBe(false) + + const sparse = new Array(2) + sparse[1] = 'value' + expect(isRemoteJsonValue(sparse)).toBe(false) + const disguisedSparse = Object.assign(new Array(2), { extra: true }) + disguisedSparse[1] = 'value' + expect(isRemoteJsonValue(disguisedSparse)).toBe(false) + expect(isRemoteJsonValue([undefined])).toBe(false) + + const symbolic = { [Symbol('value')]: true } + expect(isRemoteJsonValue(symbolic)).toBe(false) + const hidden = {} + Object.defineProperty(hidden, 'value', { value: true }) + expect(isRemoteJsonValue(hidden)).toBe(false) + expect(isRemoteJsonValue({ nested: undefined })).toBe(false) + }) +}) + +describe('Remote stream client protocol', () => { + it('rejects the removed logical-stream input message', () => { + expect(() => parseRemoteStreamClientMessage(JSON.stringify({ + type: 'input', streamId: 'stream-1', value: { answer: true }, + }))).toThrow('api gateway: invalid Remote stream client message') + }) +}) diff --git a/packages/api/gateway/tests/stream-protocol.host.spec.ts b/packages/api/gateway/tests/stream-protocol.host.spec.ts new file mode 100644 index 0000000000..f353dce901 --- /dev/null +++ b/packages/api/gateway/tests/stream-protocol.host.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + parseRemoteStreamClientMessage, + parseRemoteStreamServerMessage, +} from '../src/stream-protocol.ts' + +describe('Remote stream wire protocol', () => { + it('accepts every client message variant', () => { + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 }, + }))).toEqual({ + type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 }, + }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'cancel', streamId: 'stream-1', + }))).toEqual({ type: 'cancel', streamId: 'stream-1' }) + }) + + it.each([ + { type: 'open', streamId: '', endpoint: 'feed/follow', payload: {} }, + { type: 'open', streamId: 'stream-1', endpoint: '', payload: {} }, + { type: 'open', streamId: 'stream-1', endpoint: 'feed/follow' }, + { type: 'cancel', streamId: 'stream-1', extra: true }, + { type: 'unknown', streamId: 'stream-1' }, + ])('rejects an invalid client message: %j', (message) => { + expect(() => parseRemoteStreamClientMessage(JSON.stringify(message))) + .toThrow('api gateway: invalid Remote stream client message') + }) + + it('accepts every server message variant', () => { + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', value: null, + }))).toEqual({ type: 'item', streamId: 'stream-1', value: null }) + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', + }))).toEqual({ type: 'item', streamId: 'stream-1' }) + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'error', + streamId: 'stream-1', + error: { code: 'offline', message: 'connection lost', details: {} }, + }))).toEqual({ + type: 'error', + streamId: 'stream-1', + error: { code: 'offline', message: 'connection lost', details: {} }, + }) + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'end', streamId: 'stream-1', + }))).toEqual({ type: 'end', streamId: 'stream-1' }) + }) + + it.each([ + { type: 'item', streamId: '', value: 'item' }, + { type: 'item', streamId: 'stream-1', extra: true }, + { type: 'end', streamId: 'stream-1', extra: true }, + { type: 'error', streamId: 'stream-1', error: [] }, + { type: 'error', streamId: 'stream-1', error: { code: 1, message: 'failure', details: {} } }, + { type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 1, details: {} } }, + { type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 'failure', details: [] } }, + { type: 'unknown', streamId: 'stream-1' }, + ])('rejects an invalid server message: %j', (message) => { + expect(() => parseRemoteStreamServerMessage(JSON.stringify(message))) + .toThrow('api gateway: invalid Remote stream server message') + }) + + it.each(['not json', 'null', '[]', '1'])('rejects a non-message payload: %s', (text) => { + expect(() => parseRemoteStreamServerMessage(text)).toThrow('api gateway: Remote stream message') + }) +}) diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts new file mode 100644 index 0000000000..9746943b63 --- /dev/null +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -0,0 +1,246 @@ +import { once } from 'node:events' +import { createServer, type Server } from 'node:http' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { + RemoteStreamMuxServer, + type RemoteStreamFailureMapper, + type RemoteStreamOpener, +} from '../src/stream-server.ts' + +interface RunningMux { + readonly http: Server + readonly mux: RemoteStreamMuxServer + readonly url: string +} + +const running = new Set() + +afterEach(async () => { + await Promise.all([...running].map(async (entry) => { + running.delete(entry) + await entry.mux.close().catch(() => undefined) + await closeHttp(entry.http) + })) +}) + +describe('Remote stream mux server carrier lifecycle', () => { + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + + const binary = await connect(entry.url) + const binaryClosed = once(binary, 'close') + binary.send(Buffer.from('{}')) + const binaryEvent = await binaryClosed + expect(binaryEvent[0]).toBe(1003) + + const malformed = await connect(entry.url) + const malformedClosed = once(malformed, 'close') + malformed.send('not json') + const malformedEvent = await malformedClosed + expect(malformedEvent[0]).toBe(1008) + expect(String(malformedEvent[1])).toBe('invalid Remote stream request') + + const duplicate = await connect(entry.url) + const longId = 'same'.repeat(100) + duplicate.send(openFrame(longId)) + duplicate.send(openFrame(longId)) + const duplicateEvent = await once(duplicate, 'close') + expect(duplicateEvent[0]).toBe(1008) + expect(String(duplicateEvent[1])).toBe('invalid Remote stream request') + + const noInput = await connect(entry.url) + noInput.send(openFrame('no-input')) + noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' })) + const noInputEvent = await once(noInput, 'close') + expect(noInputEvent[0]).toBe(1008) + expect(String(noInputEvent[1])).toBe('invalid Remote stream request') + }) + + it('accepts all ws text representations and terminates a carrier error', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + const client = await connect(entry.url) + const serverSocket = acceptedSocket(entry.mux) + const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' }) + + serverSocket.emit('message', [Buffer.from(cancel)], false) + serverSocket.emit('message', Uint8Array.from(Buffer.from(cancel)).buffer, false) + + const closed = once(client, 'close') + serverSocket.emit('error', new Error('fixture carrier failure')) + await closed + }) + + it('does not send an end frame after clean source cancellation', async () => { + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + let returned!: () => void + const didReturn = new Promise((resolve) => { returned = resolve }) + const entry = await startMux(async (_endpoint, _payload, signal) => { + opened() + return cleanlyCancelled(signal, returned) + }) + const client = await connect(entry.url) + const frames: unknown[] = [] + client.on('message', (data) => { + if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame') + frames.push(JSON.parse(data.toString('utf8')) as unknown) + }) + client.send(openFrame('cancelled')) + await didOpen + client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' })) + await didReturn + await new Promise((resolve) => { setImmediate(resolve) }) + expect(frames).toEqual([]) + client.close() + await once(client, 'close') + }) + + it('closes the carrier when ws reports an item write failure', async () => { + let release!: () => void + const released = new Promise((resolve) => { release = resolve }) + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + const entry = await startMux(async () => delayedItem(released, opened)) + const client = await connect(entry.url) + client.send(openFrame('write-failure')) + await didOpen + const serverSocket = acceptedSocket(entry.mux) + const mutable = serverSocket as unknown as { + send(data: unknown, callback: (error?: Error) => void): void + } + mutable.send = (_data, callback): void => { + callback(new Error('fixture ws write failure')) + } + + const closed = once(client, 'close') + release() + const closeEvent = await closed + expect(closeEvent[0]).toBe(1011) + expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') + }) + + it('contains an item produced after its socket closes', async () => { + let release!: () => void + const released = new Promise((resolve) => { release = resolve }) + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + let returned!: () => void + const didReturn = new Promise((resolve) => { returned = resolve }) + const entry = await startMux(async () => delayedItem(released, opened, returned)) + const client = await connect(entry.url) + client.send(openFrame('late-item')) + await didOpen + const serverSocket = acceptedSocket(entry.mux) + client.close() + await once(client, 'close') + await vi.waitFor(() => { expect(serverSocket.readyState).toBe(WebSocket.CLOSED) }) + release() + await didReturn + }) + + it('terminates active sockets on close and reports a repeated close', async () => { + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + let returned!: () => void + const didReturn = new Promise((resolve) => { returned = resolve }) + const entry = await startMux(async (_endpoint, _payload, signal) => { + opened() + return cleanlyCancelled(signal, returned) + }) + const client = await connect(entry.url) + client.send(openFrame('active')) + await didOpen + + const closed = once(client, 'close') + await entry.mux.close() + running.delete(entry) + await closed + await didReturn + await expect(entry.mux.close()).rejects.toThrow() + await closeHttp(entry.http) + }) +}) + +const mapFailure: RemoteStreamFailureMapper = error => ({ + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, +}) + +async function startMux(open: RemoteStreamOpener): Promise { + const mux = new RemoteStreamMuxServer(open, mapFailure) + const http = createServer() + http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) + await new Promise((resolve, reject) => { + http.once('error', reject) + http.listen(0, '127.0.0.1', () => { + http.off('error', reject) + resolve() + }) + }) + const address = http.address() + if (address === null || typeof address === 'string') throw new Error('fixture HTTP server has no TCP port') + const entry = { http, mux, url: `ws://127.0.0.1:${String(address.port)}` } + running.add(entry) + return entry +} + +async function connect(url: string): Promise { + const socket = new WebSocket(url) + await once(socket, 'open') + return socket +} + +function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket { + const exposed = mux as unknown as { server: { clients: Set } } + const socket = [...exposed.server.clients][0] + if (socket === undefined) throw new Error('fixture mux has no accepted socket') + return socket +} + +function openFrame(streamId: string): string { + return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} }) +} + +async function *waitForAbort(signal: AbortSignal): AsyncIterable { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +async function *cleanlyCancelled(signal: AbortSignal, returned: () => void): AsyncIterable { + try { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + returned() + } +} + +async function *delayedItem( + released: Promise, + opened: () => void, + returned: () => void = () => {}, +): AsyncIterable { + try { + opened() + await released + yield 'item' + } finally { + returned() + } +} + +async function closeHttp(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} diff --git a/packages/api/gateway/tsconfig.client.json b/packages/api/gateway/tsconfig.client.json index bbf7d8b19f..31df1266af 100644 --- a/packages/api/gateway/tsconfig.client.json +++ b/packages/api/gateway/tsconfig.client.json @@ -6,7 +6,13 @@ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" }, "files": [ - "src/client/index.ts" + "src/client/index.ts", + "src/client/journal-stream.ts", + "src/client/remote-events.ts", + "src/client/remote-stream.ts", + "src/client/snapshot-stream.ts", + "src/client/stream-client.ts", + "src/stream-protocol.ts" ], "references": [ { @@ -17,6 +23,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../util/crypto" } ] } diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 14f16b5bf5..46d1b3a88d 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -8,6 +8,8 @@ "files": [ "src/index.ts", "src/invariant.ts", + "src/stream-protocol.ts", + "src/stream-server.ts", "src/types.ts" ], "references": [ @@ -23,6 +25,9 @@ { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../../host/webserver" + }, { "path": "../../typert/protocol" } diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index b9ff0c0323..fd100820c1 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: 18d39c6e86f114d2aac2f24e5d15c13335eab020 -README.zh.md: bf3dfbbaa6e0c7bd1da8398977837d4cb19d4688 +README.md: 0c4f0fcab4a741f457f1ffbdd9a4ba7688b9d32c +README.zh.md: f83e31e63b57f09b16403df911154c9d93b07958 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 18d39c6e86..0c4f0fcab4 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -2,19 +2,21 @@ English | [中文](README.zh.md) -Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns the forwarded-event selection and registers its application event source with API Gateway; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. -`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for Typert `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. +[`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package only selects and mounts that generated Session contribution; it does not duplicate activation policy. -The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The current Client assembly mounts Commands, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, Session Controller, and Workspace Controller contributions. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. -This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. +This package owns no physical transport or Host service discovery. It projects the application selection into generated Remote contributions and an independent Host event source per Client; API Gateway owns endpoints, carriers, cancellation, and reconnection. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. ## Forwarded Host events -`src/remote-events.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host cordis events this application forwards to consumers verbatim — no projection, no redaction, no renaming — and therefore the legal key set of `ctx.remote.$on`; the type-only `src/types.ts` derives its selection face. Forwarding one more event is an entry in that array and nothing else: the type projection, the consumer key face, and the Host forwarding loop all derive from it. +`src/remote-events.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host Cordis events this application forwards without renaming, and therefore the legal key set of `ctx.remote.$on`; each entry also selects ordinary emission or Agent-scoped waterfall delivery. The type-only `src/types.ts` derives its selection face. Forwarding one more event requires one entry in that array: the type projection, consumer key face, and Host forwarding loop all derive from it. -The listener signature is not restated here. Each allowlisted event's cordis `Events` declaration lives in its owner package's client-safe `./types` export (`dsh-agent-presets`, `dsh-commands`, `dsh-credentials`, `dsh-llm`, `dsh-settings`), and both faces of this package pull those declarations in, so "forwarded verbatim" holds by construction rather than by proof. The Host face additionally asserts the list against `TypertForwardableEvent`, which rejects a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way. +The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. + +The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active. Withdrawing the registration aborts active streams; API Proxy does not participate in event forwarding or Connection generation. ## Build boundary @@ -28,7 +30,7 @@ The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bun ## Model Experience -None, as this BFF selects Remote application methods and identity policy but registers nothing model-facing. +None, as this BFF selects Remote application methods and forwarded events but registers nothing model-facing. #### KV Cache effect @@ -38,4 +40,4 @@ No direct effect; mounted Host capabilities own any model-visible behavior they - The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. - Additional capabilities require an explicit `/remote` value import and mount in this assembly. -- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`. +- Ordinary forwarded events are not replayed; state that requires reliable recovery needs an owner-provided query, cursor, or opening baseline. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index bf3dfbbaa6..f83e31e63b 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -2,19 +2,21 @@ [English](README.md) | 中文 -为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口拥有转发事件名单并向 API Gateway 注册应用事件 source;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 -`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 Typert `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 +[`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 Agent 与 Session 身份策略,包括供其他 namespace 使用的 Typert lookup resolver。本包只选择并挂载生成的 Session contribution,不复制激活策略。 -当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`)。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +当前 Client 组合挂载 Commands、Goal、动态 Cordis、文件与 Session 引用、只读 Host 插件清单、消息反馈、Session Controller 和 Workspace Controller contribution。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 -本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 +本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution 和唯一的 Host Cordis event source;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 ## 转发的 Host 事件 -`src/remote-events.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集;只含类型的 `src/types.ts` 派生其选择面。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。 +`src/remote-events.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`,即本应用不改名转发给消费端的 Host Cordis 事件名单;每个条目还会选择普通发送或 Agent-scoped waterfall 投递。该名单同时就是 `ctx.remote.$on` 的合法键集,只含类型的 `src/types.ts` 派生其选择面。多转发一个事件只需在该数组里加一项:类型投影、消费端键面与 Host 转发循环全部由它派生。 -监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-agent-presets`、`dsh-commands`、`dsh-credentials`、`dsh-llm`、`dsh-settings`),本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypertForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。 +监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 Agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 + +Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项能证明增量投递已就绪。撤回注册会中止活动 stream;API Proxy 不参与事件转发或 Connection generation。 ## 构建边界 @@ -29,7 +31,7 @@ ## 模型体验 -无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 +无,因为该 BFF 只选择 Remote 应用方法和转发事件,不注册任何模型接口。 #### KV Cache 影响 @@ -39,4 +41,4 @@ - 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 - 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 -- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。 +- 只有仍在等待的作用域 waterfall 会在重连后重放;单向通知仍是相互隔离的 best-effort 投递。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 102344ba91..0724012e77 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-api-remotes", - "description": "Remote BFF assembly and Host Agent/Session lookup policy", + "description": "Remote BFF assembly for application-selected Host capabilities", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -55,12 +55,15 @@ "lib/types/**/*.d.ts" ], "dependencies": { + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", @@ -71,16 +74,17 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-user-questions": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", @@ -91,10 +95,9 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-user-questions": "workspace:^" } } diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts deleted file mode 100644 index 3551d6b1a8..0000000000 --- a/packages/api/remotes/src/agent-lookup.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** Host BFF policy for resolving Remote Agent and Session identities. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-persistence' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' -import type {} from '@deepseek-ai/dsh-typert-registry' - -/** Caller-facing failures preserved by the Gateway's RPC adapter. */ -export type ApiRemoteLookupError = - | { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } } - | { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } } - | { readonly code: 'internal'; readonly message: string; readonly details: Record } - -/** Result of resolving one session identity to its live Agent. */ -export type ApiRemoteAgentResult = - | { readonly agent: Agent } - | { readonly error: ApiRemoteLookupError } - -/** Resume configuration supplied by the owning Host composition. */ -export interface ApiRemoteAgentOptions { - /** Read the per-Agent defaults when a cold identity must resume. */ - readonly agentOptions?: () => AgentOptions - /** - * Build the Host-specific Agent-scope composition completed before - * publication. Keyed by the resumed session itself because what a Host - * installs may depend on what that session recorded: an agent preset fixes - * the tools its history was produced under, so rebuilding it under another - * composition would replay tool calls the agent can no longer make. The - * events come along because a session's own record of such a choice may be - * an event rather than a header field. - * @param session - the resumed session's persisted header and event log. - * @returns the Agent-scope setup to run before publication. - */ - readonly setup?: ( - session: { meta: SessionHeader; events: readonly SessionEvent[] }, - ) => AgentSetup | Promise -} - -/** Cold identity absent from the durable session store. */ -export class ApiRemoteSessionNotFound extends Error {} - -/** Session identity whose lifecycle belongs to subagent routing. */ -export class ApiRemoteSubagentSessionOwnership extends Error { - /** - * Construct the ownership fence. - * @param sessionId - identity reserved to subagent routing. - */ - constructor(readonly sessionId: SessionId) { - super(`session "${sessionId}" is a subagent session; use subagent delivery`) - } -} - -/** - * Test whether generic Host routing must leave an identity to subagent routing. - * @param ctx - Host Context carrying the live Agent registry. - * @param session - attached or live Session metadata. - * @param agent - live Agent when one is registered. - * @returns whether generic Remote and legacy API calls must reject the identity. - */ -export function hasApiRemoteSubagentOwner( - ctx: Context, - session: Pick, - agent: Agent | undefined, -): boolean { - if (session.header.origin === 'subagent') return true - const parentId = session.header.parentSession - if (parentId === undefined || agent === undefined) return false - const parent = ctx.agents.get(parentId) - return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) -} - -/** - * Build the stable caller-facing ownership rejection. - * @param sessionId - identity reserved to subagent routing. - * @returns the existing `agent-busy` RPC shape. - */ -export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError { - return { - code: 'agent-busy', - message: `session "${sessionId}" is owned by subagent routing`, - details: { reason: 'use subagent delivery for this child session' }, - } -} - -/** - * Inspect one cold served session without repairing, resuming, or publishing it. - * @param ctx - Host Context carrying the optional persistence provider. - * @param sessionId - durable identity to inspect. - * @returns detached metadata and events for a servable session. - * @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session. - */ -export async function inspectApiRemoteSession( - ctx: Context, - sessionId: SessionId, -): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) { - throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') - } - const meta = (await persistence.list()).find(candidate => candidate.id === sessionId) - if (meta === undefined || meta.cwd === undefined) { - throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) - } - const inspected = await persistence.inspect(sessionId) - if (inspected.meta.cwd === undefined) { - throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) - } - return { meta: inspected.meta, events: [...inspected.events] } -} - -/** - * Create the Host's shared Agent resolver and configure Agent/Session Typert lookups. - * Live Agents are reused, ordinary cold sessions resume once per identity, and - * subagent-owned identities retain the legacy `agent-busy` fence. - * @param ctx - owning Host Context. - * @param options - defaults and Agent-scope setup used only for cold resume. - * @returns resolver shared by legacy API Proxy methods and Typert lookups. - */ -export function createApiRemoteAgentResolver( - ctx: Context, - options: ApiRemoteAgentOptions, -): (sessionId: SessionId) => Promise { - const resumes = new Map>() - - const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => { - const live = ctx.agents.get(sessionId) - if (live === undefined) return undefined - if (hasApiRemoteSubagentOwner(ctx, live.session, live)) { - return { error: apiRemoteSubagentOwnershipError(sessionId) } - } - return { agent: live } - } - - const agentFor = async (sessionId: SessionId): Promise => { - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { - return { error: apiRemoteSubagentOwnershipError(sessionId) } - } - let resume = resumes.get(sessionId) - if (resume === undefined) { - resume = (async () => { - try { - const inspected = await inspectApiRemoteSession(ctx, sessionId) - if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) { - throw new ApiRemoteSubagentSessionOwnership(sessionId) - } - // Built from the inspected session before the published re-checks - // below, so those stay adjacent to `resume` and a Host setup that - // awaits (composing a preset, say) does not widen the collision - // window. - const setup = options.setup === undefined ? undefined : await options.setup(inspected) - const publishedSession = ctx.sessions.get(sessionId) - const publishedAgent = ctx.agents.get(sessionId) - if (publishedSession !== undefined - && hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) { - throw new ApiRemoteSubagentSessionOwnership(sessionId) - } - const handle = await ctx.agents.resume({ - resumeSessionId: sessionId, - ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() }, - ...setup === undefined ? {} : { setup }, - }) - return handle.agent - } finally { - resumes.delete(sessionId) - } - })() - resumes.set(sessionId, resume) - } - try { - return { agent: await resume } - } catch (error: unknown) { - if (error instanceof ApiRemoteSessionNotFound) { - return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } } - } - if (error instanceof ApiRemoteSubagentSessionOwnership) { - return { error: apiRemoteSubagentOwnershipError(error.sessionId) } - } - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { - return { error: apiRemoteSubagentOwnershipError(sessionId) } - } - return { - error: { - code: 'internal', - message: `resume failed for session "${sessionId}": ${String(error)}`, - details: {}, - }, - } - } - } - - ctx.inject(['typert'], (typeCtx) => { - const resolveAgent = async (sessionId: SessionId): Promise => { - const found = await agentFor(sessionId) - if ('error' in found) throw new TypertLookupFailure(found.error) - return found.agent - } - typeCtx.typert.lookups.configure('agent', resolveAgent) - typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) - typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx) - }) - - return agentFor -} diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 6a5164f160..db78433d82 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -8,9 +8,11 @@ import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' -import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' +import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' -export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol' +export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-file-reference/remote' @@ -18,6 +20,11 @@ export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' +export type {} from '@deepseek-ai/dsh-api-session-controller/remote' +export type * from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' +export type * from '@deepseek-ai/dsh-api-workspace-controller/types' +export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -30,6 +37,9 @@ export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-llm/types' export type {} from '@deepseek-ai/dsh-agent-presets/types' export type {} from '@deepseek-ai/dsh-settings/types' +export type {} from '@deepseek-ai/dsh-user-approval/types' +export type {} from '@deepseek-ai/dsh-user-questions/types' +export type {} from '@deepseek-ai/dsh-api-session-controller/types' /** * The carrier's Client-facing types, re-exported so a business package names one @@ -37,14 +47,12 @@ export type {} from '@deepseek-ai/dsh-settings/types' * the carrier's runtime values stay behind their own module edge. */ export type { - ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient, + ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, + CredentialView, DirectoryListing, DiscoveredModelView, IApiClient, MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, - MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt, - RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem, - SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, - SubagentAddress, SubagentCatalog, JobView, ToolCallView, ToolEventView, ToolResultView, - WorkspaceId, WorkspaceView, + RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, + SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, + SubagentAddress, SubagentCatalog, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -95,10 +103,21 @@ export type { JsonValue } from '@deepseek-ai/dsh-session/types' export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' +/** Failure vocabulary exposed by the assembled Client data layer. */ +export type ClientFailure = + | import('@deepseek-ai/dsh-client-connection/client').RpcError + | import('@deepseek-ai/dsh-api-session-controller/types').SessionError + | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError + +/** Success or failure returned by Client operations spanning both API families. */ +export type ClientResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ClientFailure } + declare module '@deepseek-ai/cordis' { interface Context { /** Generated Remote namespaces selected by this Client assembly. */ - remote: TypertClientRemote + remote: ClientRemote } } @@ -116,6 +135,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, + sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 6572c11938..4d0256e162 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -1,6 +1,15 @@ /** Host BFF entry and Loader shell for the Remote contribution assembly. */ -import type { TypertForwardableEvent } from '@deepseek-ai/dsh-typert-protocol' +import type { Context } from '@deepseek-ai/cordis' +import type { + TypertRemoteEventDispatch, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, +} from '@deepseek-ai/dsh-api-gateway' +import { carrierKeyOf } from '@deepseek-ai/dsh-scope' +import { isJsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-session' import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' // The owner packages' client-safe `./types` exports carry the cordis `Events` @@ -13,32 +22,143 @@ import type {} from '@deepseek-ai/dsh-credentials/types' import type {} from '@deepseek-ai/dsh-llm/types' import type {} from '@deepseek-ai/dsh-agent-presets/types' import type {} from '@deepseek-ai/dsh-settings/types' +import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-user-questions' +export type {} from '@deepseek-ai/dsh-api-session-controller/types' -export { - ApiRemoteSessionNotFound, - ApiRemoteSubagentSessionOwnership, - apiRemoteSubagentOwnershipError, - createApiRemoteAgentResolver, - hasApiRemoteSubagentOwner, - inspectApiRemoteSession, -} from './agent-lookup.ts' -export type { - ApiRemoteAgentOptions, - ApiRemoteAgentResult, - ApiRemoteLookupError, -} from './agent-lookup.ts' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' export type { ApiRemoteForwardedEvent } from './types.ts' -// Shape gate over the allowlist, kept in the Host face because the Host's event -// vocabulary is the authoritative one. It pins three things at compile time: -// every entry NAMES a declared event (the predicate is keyed on `keyof -// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not -// `unknown`, which is how "must not depend on AgentScope" is stated statically), -// and every entry is ONE-WAY (a waterfall or bail shape returns something other -// than void and is excluded). Widening the array to an event that fails any of -// these fails here, not on the wire. -API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[] +/** Required Host service: the Gateway owns the physical Remote stream mux. */ +export const inject = ['typertGateway'] -/** Host plugin body; the selected contributions mount only in Client environments. */ -export function apply(): void {} +/** Host plugin body registering this application's selected Cordis event source. */ +export function apply(ctx: Context): void { + ctx.effect( + () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx)), + 'api-remotes: forwarded Cordis event source', + ) +} + +/** Create the sole queue and listener set consumed by the registered Gateway. */ +function remoteEventSource(ctx: Context): TypertRemoteEventSource { + return (signal) => { + const queue = new RemoteEventQueue() + const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => { + if (mode === 'emit') { + return ctx.on(event as never, ((...args: unknown[]) => { + queue.push({ event, args: assertJsonArgs(event, args) }) + }) as never) + } + return ctx.on(event as never, (function ( + this: unknown, + request: object, + next: () => unknown, + ) { + const subject = carrierKeyOf(this) + if (subject === undefined) return next() + const value = Reflect.get(subject, 'ctx') as unknown + if (typeof value !== 'object' || value === null) { + throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} has no live Context`) + } + return forwardWaterfall( + queue, + event, + request, + { value: value as Context, subject }, + next, + ) + }) as never) + }) + return queue.iterate(signal, () => { + for (const dispose of disposers) dispose() + }) + } +} + +/** One pull-driven queue bridging synchronous Cordis listeners to an AsyncIterable. */ +class RemoteEventQueue { + private readonly buffer: TypertRemoteEventDispatch[] = [] + private waiter: (() => void) | undefined + private done = false + + push(frame: TypertRemoteEventDispatch): boolean { + if (this.done) return false + this.buffer.push(frame) + this.waiter?.() + return true + } + + private end(reason: unknown): void { + if (this.done) return + this.done = true + const buffered = this.buffer.splice(0) + for (const dispatch of buffered) { + if ('context' in dispatch) dispatch.reject(reason) + } + this.waiter?.() + } + + async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator { + const abort = (): void => { this.end(remoteEventSourceEndReason(signal)) } + signal.addEventListener('abort', abort, { once: true }) + try { + while (true) { + if (this.done || signal.aborted) return + while (this.buffer.length > 0) yield this.buffer.shift() as TypertRemoteEventDispatch + await new Promise((resolve) => { this.waiter = resolve }) + this.waiter = undefined + } + } finally { + signal.removeEventListener('abort', abort) + this.end(remoteEventSourceEndReason(signal)) + cleanup() + } + } +} + +/** + * Normalize an event-source shutdown for pending Host waterfalls. + * @param signal - source lifetime whose reason wins after cancellation. + * @returns the cancellation reason or an unexpected-end failure. + */ +function remoteEventSourceEndReason(signal: AbortSignal): unknown { + if (signal.aborted) return signal.reason + return new Error('api-remotes: forwarded Remote event source ended') +} + +/** Bridge one Cordis waterfall listener through the Gateway-owned pending event. */ +function forwardWaterfall( + queue: RemoteEventQueue, + event: string, + request: object, + context: TypertRemoteEventInvocation['context'], + next: () => unknown, +): Promise { + const settled = Promise.withResolvers() + const dispatch: TypertRemoteEventInvocation = { + event, + request, + context, + resolve: (outcome: TypertRemoteEventOutcome) => { + if (outcome.kind === 'result') { + settled.resolve(outcome.value) + return + } + void Promise.resolve().then(next).then(settled.resolve, settled.reject) + }, + reject: settled.reject, + } + if (!queue.push(dispatch)) void Promise.resolve().then(next).then(settled.resolve, settled.reject) + return settled.promise +} + +/** Reject an allowlisted event whose runtime arguments are not lossless JSON data. */ +function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] { + for (const [index, arg] of args.entries()) { + if (!isJsonValue(arg)) { + throw new Error(`forwarded host event "${event}" argument ${String(index)} is not lossless JSON data`) + } + } + return args as JsonValue[] +} diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 949778cbe8..bf98fe536c 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -6,24 +6,26 @@ * type-only. */ +import { SESSION_CONTROLLER_REMOTE_EVENTS } from '@deepseek-ai/dsh-api-session-controller/remote-events' +import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' + /** - * Host events this application forwards to consumers verbatim: no projection, - * no redaction, no renaming. The wire name is the Host cordis event name and - * the payload is its argument list, so this array is simultaneously the whole - * control point over what a consumer can receive and the legal key set of - * `ctx.remote.$on`. Forwarding one more event is an entry here and nothing - * else. + * Host events this application forwards without renaming. The explicit mode is + * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`. */ export const API_REMOTE_FORWARDED_EVENTS = [ - 'agent-preset/selected', - 'commands/change', - 'credentials/reference-updated', - 'cordis/request-run', - 'cordis/request-run-resolved', - 'cordis/dynamic-package', - 'cordis/dynamic-retract', - 'cordis/inspect-query', - 'cordis/inspect-query-resolved', - 'llm/adapters-updated', - 'settings/document-updated', -] as const + { event: 'agent-preset/selected', mode: 'emit' }, + { event: 'approval/request', mode: 'waterfall' }, + ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })), + { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/reference-updated', mode: 'emit' }, + { event: 'cordis/request-run', mode: 'emit' }, + { event: 'cordis/request-run-resolved', mode: 'emit' }, + { event: 'cordis/dynamic-package', mode: 'emit' }, + { event: 'cordis/dynamic-retract', mode: 'emit' }, + { event: 'cordis/inspect-query', mode: 'emit' }, + { event: 'cordis/inspect-query-resolved', mode: 'emit' }, + { event: 'llm/adapters-updated', mode: 'emit' }, + { event: 'settings/document-updated', mode: 'emit' }, + { event: 'user-questions/request', mode: 'waterfall' }, +] as const satisfies readonly TypertForwardableEventEntry[] diff --git a/packages/api/remotes/src/types.ts b/packages/api/remotes/src/types.ts index 6e14546261..cbf7572d8a 100644 --- a/packages/api/remotes/src/types.ts +++ b/packages/api/remotes/src/types.ts @@ -12,7 +12,7 @@ import type { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' /** Type projection of the allowlist; the consumer and the Host read this one. */ -export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event'] declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertRemoteEventSelection extends Record {} diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts deleted file mode 100644 index 743059e73c..0000000000 --- a/packages/api/remotes/tests/agent-lookup.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' -import TypertRegistry from '@deepseek-ai/dsh-typert-registry' - -const sid = (value: string): SessionId => value as SessionId - -function header(id: SessionId): SessionHeader { - return { version: 0, id, createdAt: 1, cwd: '/proj' } -} - -async function createContext(): Promise { - const ctx = new Context() - await ctx.plugin(TypertRegistry) - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - return ctx -} - -function provideSession( - ctx: Context, - meta: SessionHeader, - inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>, -): void { - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - inspect, - locate: () => undefined, - } as never) -} - -function stubAgent(ctx: Context, session: Session): Agent { - return { id: session.id, session, status: 'idle', ctx } as Agent -} - -describe('API Remote Agent resolver races', () => { - it('maps an inspected session without a cwd to session-not-found', async () => { - const ctx = await createContext() - const sessionId = sid('missing-after-inspect') - const meta = header(sessionId) - provideSession(ctx, meta, () => Promise.resolve({ - meta: { ...meta, cwd: undefined } as unknown as SessionHeader, - events: [], - })) - - const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) - - expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } }) - await ctx.fiber.dispose() - }) - - it('resumes through a concurrently attached ordinary Session without optional defaults', async () => { - const ctx = await createContext() - const sessionId = sid('ordinary-attach-race') - const meta = header(sessionId) - let published: Session | undefined - provideSession(ctx, meta, () => { - published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) - return Promise.resolve({ meta, events: [] }) - }) - const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { - if (published === undefined) throw new Error('Session was not published') - return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() } - }) - - const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) - - expect(result).toMatchObject({ agent: { id: sessionId } }) - expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId }) - await ctx.fiber.dispose() - }) - - it('rejects a subagent Session published after durable inspection', async () => { - const ctx = await createContext() - const sessionId = sid('owned-attach-race') - const meta = header(sessionId) - provideSession(ctx, meta, () => { - ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) - return Promise.resolve({ meta, events: [] }) - }) - const resume = vi.spyOn(ctx.agents, 'resume') - - const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) - - expect(result).toMatchObject({ error: { code: 'agent-busy' } }) - expect(resume).not.toHaveBeenCalled() - await ctx.fiber.dispose() - }) - - it('reclassifies failed resumes after a live or attached subagent wins publication', async () => { - for (const winner of ['agent', 'session'] as const) { - const ctx = await createContext() - const sessionId = sid(`owned-${winner}-resume-race`) - const meta = header(sessionId) - provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] })) - vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { - const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) - if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session)) - throw new Error('session id already published') - }) - - const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) - - expect(result).toMatchObject({ error: { code: 'agent-busy' } }) - await ctx.fiber.dispose() - } - }) - - it('uses the shared cold-resume policy for the Agent Host Context', async () => { - const ctx = await createContext() - const sessionId = sid('context-cold-resume') - const meta = header(sessionId) - let published: Session | undefined - provideSession(ctx, meta, () => { - published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) - return Promise.resolve({ meta, events: [] }) - }) - const agentCtx = ctx.extend() - vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { - if (published === undefined) throw new Error('Session was not published') - return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() } - }) - const defaultProvider = ctx.typert.contexts.getHost('agent') - createApiRemoteAgentResolver(ctx, {}) - await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) - const provider = ctx.typert.contexts.getHost('agent') - if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') - - await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx) - await ctx.fiber.dispose() - }) - - it('applies the subagent ownership fence to the Agent Host Context', async () => { - const ctx = await createContext() - const sessionId = sid('context-owned-subagent') - const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) - ctx.agents.register(stubAgent(ctx.extend(), session)) - const defaultProvider = ctx.typert.contexts.getHost('agent') - createApiRemoteAgentResolver(ctx, {}) - await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) - const provider = ctx.typert.contexts.getHost('agent') - if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') - - const resolution = provider.resolve(sessionId) - await expect(resolution).rejects.toBeInstanceOf(TypertLookupFailure) - await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) - await ctx.fiber.dispose() - }) -}) diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts new file mode 100644 index 0000000000..eefe64e664 --- /dev/null +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -0,0 +1,216 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' +import type { + TypertRemoteEventInvocation, + TypertRemoteEventSource, +} from '@deepseek-ai/dsh-api-gateway' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { describe, expect, it } from 'vitest' +import { apply, inject } from '../src/index.ts' + +interface GatewayProbe { + source: TypertRemoteEventSource | undefined + removals: number + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise +} + +async function setup(): Promise<{ + readonly ctx: Context + readonly gateway: GatewayProbe + readonly fiber: Fiber +}> { + const ctx = new Context() + const gateway: GatewayProbe = { + source: undefined, + removals: 0, + registerRemoteEvents(source) { + gateway.source = source + return async () => { + if (gateway.source !== source) return + gateway.source = undefined + gateway.removals += 1 + } + }, + } + ctx.reflect.provide('typertGateway', gateway) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber + return { ctx, gateway, fiber } +} + +function sourceOf(gateway: GatewayProbe): TypertRemoteEventSource { + if (gateway.source === undefined) throw new Error('fixture Gateway has no Remote event source') + return gateway.source +} + +function emitRaw(ctx: Context, event: string, args: readonly unknown[]): void { + const emit = ctx.emit.bind(ctx) as unknown as (name: string, ...values: readonly unknown[]) => void + emit(event, ...args) +} + +function waterfallRaw( + ctx: Context, + target: object, + event: string, + args: readonly unknown[], + next: () => Promise, +): Promise { + const waterfall = ctx.waterfall.bind(ctx) as unknown as ( + receiver: object, + name: string, + ...values: readonly unknown[] + ) => Promise + return waterfall(target, event, ...args, next) +} + +function invocationOf(value: unknown): TypertRemoteEventInvocation { + if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'context')) { + throw new Error('fixture did not receive a scoped Remote Event invocation') + } + return value as TypertRemoteEventInvocation +} + +describe('Remote event Host source', () => { + it('gives each Client stream an independent allowlisted event queue', async () => { + const { ctx, gateway, fiber } = await setup() + const firstAbort = new AbortController() + const secondAbort = new AbortController() + const first = sourceOf(gateway)(firstAbort.signal)[Symbol.asyncIterator]() + const second = sourceOf(gateway)(secondAbort.signal)[Symbol.asyncIterator]() + + emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1]) + await expect(first.next()).resolves.toEqual({ + done: false, + value: { event: 'settings/document-updated', args: ['ui-theme', 1] }, + }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'settings/document-updated', args: ['ui-theme', 1] }, + }) + + const firstDone = first.next() + firstAbort.abort(new Error('first Client disconnected')) + emitRaw(ctx, 'commands/change', []) + await expect(firstDone).resolves.toEqual({ done: true, value: undefined }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'commands/change', args: [] }, + }) + + const secondDone = second.next() + secondAbort.abort(new Error('second Client disconnected')) + await expect(secondDone).resolves.toEqual({ done: true, value: undefined }) + + await fiber.dispose() + expect(gateway.source).toBeUndefined() + expect(gateway.removals).toBe(1) + await ctx.fiber.dispose() + }) + + it('rejects a non-JSON argument without poisoning the stream', async () => { + const { ctx, gateway } = await setup() + const abort = new AbortController() + const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const pending = iterator.next() + + expect(() => { + emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1n]) + }).toThrow('argument 1 is not lossless JSON data') + emitRaw(ctx, 'settings/document-updated', ['ui-theme', 2]) + await expect(pending).resolves.toEqual({ + done: false, + value: { event: 'settings/document-updated', args: ['ui-theme', 2] }, + }) + + const done = iterator.next() + abort.abort() + await expect(done).resolves.toEqual({ done: true, value: undefined }) + + const alreadyAborted = new AbortController() + alreadyAborted.abort() + await expect(sourceOf(gateway)(alreadyAborted.signal)[Symbol.asyncIterator]().next()) + .resolves.toEqual({ done: true, value: undefined }) + await ctx.fiber.dispose() + }) + + it('bridges scoped waterfall result, next delegation, and rejection', async () => { + const { ctx, gateway } = await setup() + const abort = new AbortController() + const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const agentCtx = ctx.extend() + const agent = { ctx: agentCtx } + const target = scopeTarget(ctx, agent) + const request = { questions: [], agent } + + const claimed = waterfallRaw( + ctx, + target, + 'user-questions/request', + [request], + () => Promise.resolve('host fallback'), + ) + const claimedDispatch = invocationOf((await iterator.next()).value) + expect(claimedDispatch).toMatchObject({ + event: 'user-questions/request', + request, + context: { value: agentCtx, subject: agent }, + }) + claimedDispatch.resolve({ kind: 'result', value: 'client answer' }) + await expect(claimed).resolves.toBe('client answer') + + const delegated = waterfallRaw( + ctx, + target, + 'user-questions/request', + [request], + () => Promise.resolve('host fallback'), + ) + const delegatedDispatch = invocationOf((await iterator.next()).value) + delegatedDispatch.resolve({ kind: 'next' }) + await expect(delegated).resolves.toBe('host fallback') + + const rejection = Object.assign(new Error('the user cancelled ask_user_question'), { + code: 'ASK_CANCELLED', + }) + const rejected = waterfallRaw( + ctx, + target, + 'user-questions/request', + [request], + () => Promise.resolve('host fallback'), + ) + const rejectedAssertion = expect(rejected).rejects.toBe(rejection) + const rejectedDispatch = invocationOf((await iterator.next()).value) + rejectedDispatch.reject(rejection) + await rejectedAssertion + + const done = iterator.next() + abort.abort() + await expect(done).resolves.toEqual({ done: true, value: undefined }) + await ctx.fiber.dispose() + }) + + it('rejects a queued scoped waterfall when its source is withdrawn', async () => { + const { ctx, gateway, fiber } = await setup() + const abort = new AbortController() + const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const delivery = iterator.next() + const agent = { ctx: ctx.extend() } + const reason = new Error('forwarded event source removed') + const pending = waterfallRaw( + ctx, + scopeTarget(ctx, agent), + 'user-questions/request', + [{ questions: [], agent }], + () => Promise.resolve('host fallback'), + ) + const rejected = expect(pending).rejects.toBe(reason) + + abort.abort(reason) + + await rejected + await expect(delivery).resolves.toEqual({ done: true, value: undefined }) + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 49c7276d42..eb98174378 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -54,6 +54,18 @@ { "path": "../../settings/settings" }, + { + "path": "../../interaction/user-approval" + }, + { + "path": "../../interaction/user-questions" + }, + { + "path": "../session-controller/tsconfig.client.json" + }, + { + "path": "../workspace-controller/tsconfig.client.json" + }, { "path": "../../typert/protocol" } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 61eae810f4..4dd513bdeb 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -6,7 +6,6 @@ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" }, "files": [ - "src/agent-lookup.ts", "src/index.ts", "src/invariant.ts", "src/remote-events.ts", @@ -17,7 +16,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../../core/agent" + "path": "../gateway/tsconfig.host.json" }, { "path": "../../core/session" @@ -34,20 +33,29 @@ { "path": "../../preset/agent-presets" }, - { - "path": "../../session/session-persistence" - }, { "path": "../../extensions/cordis-host-runner" }, { "path": "../../settings/settings" }, + { + "path": "../../core/scope" + }, + { + "path": "../../interaction/user-approval" + }, + { + "path": "../../interaction/user-questions" + }, { "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../typert/registry" + "path": "../session-controller/tsconfig.host.json" + }, + { + "path": "../workspace-controller/tsconfig.host.json" }, { "path": "../../typert/protocol" diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml new file mode 100644 index 0000000000..51a624d98a --- /dev/null +++ b/packages/api/session-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md +README.md: cda9349e432472a0ed9fd623afef0b689ff72f73 +README.zh.md: 2aaee8f968cf7373110e291c197adbeca21f490e diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md new file mode 100644 index 0000000000..cda9349e43 --- /dev/null +++ b/packages/api/session-controller/README.md @@ -0,0 +1,22 @@ +# Session Controller + +English | [中文](README.zh.md) + +`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state. + +Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. + +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. + +## Model Experience + +None, as invoked Agent commands own any model-visible effect. + +#### KV Cache effect + +No direct effect; model requests remain owned by the Agent and LLM packages. + +## Known Limitations and Deferred Work + +- Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. +- A failed follow resumption remains visible to the caller instead of retrying indefinitely. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md new file mode 100644 index 0000000000..2aaee8f968 --- /dev/null +++ b/packages/api/session-controller/README.zh.md @@ -0,0 +1,22 @@ +# Session Controller + +[English](README.md) | 中文 + +`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。 + +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。 + +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 + +## 模型体验 + +无,因为被调用的 Agent 命令拥有任何模型可见效果。 + +#### KV Cache 影响 + +无直接影响;模型请求仍由 Agent 和 LLM 包拥有。 + +## 已知限制与延期工作 + +- Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 +- follow 恢复失败会对调用方可见,而不会无限重试。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json new file mode 100644 index 0000000000..a6d764bf4a --- /dev/null +++ b/packages/api/session-controller/package.json @@ -0,0 +1,139 @@ +{ + "name": "@deepseek-ai/dsh-api-session-controller", + "description": "Session Remote commands, cold reads, and live control transport", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/session-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./remote-events": { + "types": "./lib/types/remote-events.d.ts", + "default": "./lib/types/remote-events.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./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" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "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-presets": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "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-subagent": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-jobs": { "optional": true }, + "@deepseek-ai/dsh-session-persistence": { "optional": true }, + "@deepseek-ai/dsh-session-projection": { "optional": true }, + "@deepseek-ai/dsh-session-projection-cache": { "optional": true }, + "@deepseek-ai/dsh-tools": { "optional": true } + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "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-subagent": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^" + } +} diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts new file mode 100644 index 0000000000..98b22a417d --- /dev/null +++ b/packages/api/session-controller/src/agent.ts @@ -0,0 +1,414 @@ +/** Agent activation, composition, and model-selection policy owned by API Session. */ + +import { mkdir } from 'node:fs/promises' +import type { Context } from '@deepseek-ai/cordis' +import { installModelSelection } from '@deepseek-ai/dsh-agent' +import type { + Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, +} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-agent-default-model' +import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' +import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-typert-registry' +import type { SessionError } from './types.ts' + +/** Cold Session identity absent from persistence. */ +export class ApiSessionNotFound extends Error {} + +/** Session identity whose lifecycle belongs to subagent routing. */ +export class ApiSessionSubagentOwnership extends Error { + /** @param sessionId - identity reserved to subagent routing. */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" is a subagent session; use subagent delivery`) + } +} + +/** Explicit-id creation attempted to adopt a Session under another cwd. */ +export class ApiSessionCwdConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedCwd: string, + readonly existingCwd: string | undefined, + ) { + super( + existingCwd === undefined + ? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"` + : `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`, + ) + } +} + +/** Explicit-id creation attempted to adopt a Session under another preset. */ +export class ApiSessionPresetConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedPreset: string, + readonly existingPreset: string | undefined, + ) { + super( + existingPreset === undefined + ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` + : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`, + ) + } +} + +/** Failures produced while resolving one ordinary Session identity to its live Agent. */ +export type ApiSessionAgentError = Extract< + SessionError, + { readonly code: 'session-not-found' | 'agent-busy' | 'internal' } +> + +/** Result of resolving one ordinary Session identity to its live Agent. */ +export type ApiSessionAgentResult = + | { readonly agent: Agent } + | { readonly error: ApiSessionAgentError } + +type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection } + +/** + * Test whether generic Session routing must leave an identity to subagent routing. + * @param ctx - Host context carrying the Agent ownership registry. + * @param session - attached or live Session whose ownership is tested. + * @param agent - live Agent when one exists for the Session. + * @returns whether subagent routing owns the Session identity. + */ +export function hasApiSessionSubagentOwner( + ctx: Context, + session: Pick, + agent: Agent | undefined, +): boolean { + if (session.header.origin === 'subagent') return true + const parentId = session.header.parentSession + if (parentId === undefined || agent === undefined) return false + const parent = ctx.agents.get(parentId) + return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) +} + +/** + * Build the stable caller-facing subagent ownership rejection. + * @param sessionId - Session identity owned by subagent routing. + * @returns a stable Session-domain failure. + */ +export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError { + return { + code: 'agent-busy', + message: `session "${sessionId}" is owned by subagent routing`, + details: { reason: 'use subagent delivery for this child session' }, + } +} + +/** + * Inspect one cold Session without repairing, resuming, or publishing it. + * @param ctx - Host context carrying Session persistence. + * @param sessionId - durable Session identity. + * @param signal - optional cancellation for persistence reads. + * @returns the persisted header and complete event prefix. + */ +export async function inspectApiSession( + ctx: Context, + sessionId: SessionId, + signal?: AbortSignal, +): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const persistence = ctx.get('sessionPersistence') + if (persistence === undefined) { + throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') + } + const meta = (await persistence.list(signal)).find(candidate => candidate.id === sessionId) + if (meta === undefined || meta.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + const inspected = await persistence.inspect(sessionId, signal) + if (inspected.meta.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + return { meta: inspected.meta, events: [...inspected.events] } +} + +/** Owns every operation that may create, resume, or configure a Web Agent. */ +export class ApiSessionAgentController { + private readonly resumes = new Map>() + private readonly creations = new Map>() + private readonly selections = new WeakMap() + private readonly imageAdmissionChains = new WeakMap>() + + /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ + constructor(private readonly ctx: Context) { + ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId) + if ('error' in found) throw new TypertLookupFailure(found.error) + return found.agent + }) + ctx.typert.lookups.configure('session', async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId) + if ('error' in found) throw new TypertLookupFailure(found.error) + return found.agent.session + }) + ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId) + if ('error' in found) throw new TypertLookupFailure(found.error) + return found.agent.ctx + }) + } + + /** + * Resolve or resume one ordinary Session, deduplicating concurrent resumes. + * @param sessionId - ordinary Session identity. + * @returns the live Agent or a stable Session-domain failure. + */ + async resolveAgent(sessionId: SessionId): Promise { + const live = this.liveAgent(sessionId) + if (live !== undefined) return live + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + return { error: apiSessionSubagentOwnershipError(sessionId) } + } + + let resume = this.resumes.get(sessionId) + if (resume === undefined) { + resume = this.resume(sessionId).finally(() => { this.resumes.delete(sessionId) }) + this.resumes.set(sessionId, resume) + } + try { + return { agent: await resume } + } catch (error: unknown) { + if (error instanceof ApiSessionNotFound) { + return { + error: { + code: 'session-not-found', + message: error.message, + details: { sessionId }, + }, + } + } + if (error instanceof ApiSessionSubagentOwnership) { + return { error: apiSessionSubagentOwnershipError(error.sessionId) } + } + const raced = this.liveAgent(sessionId) + if (raced !== undefined) return raced + const racedSession = this.ctx.sessions.get(sessionId) + if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) { + return { error: apiSessionSubagentOwnershipError(sessionId) } + } + return { + error: { + code: 'internal', + message: `resume failed for session "${sessionId}": ${String(error)}`, + details: {}, + }, + } + } + } + + /** + * Resolve one requested identity, creating or resuming it once. + * @param sessionId - requested Session identity. + * @param cwd - directory the Session must own. + * @param checkPersistedIdentity - whether to inspect a cold identity before creation. + * @param presetId - optional Agent preset the Session must own. + * @returns the matching live ordinary Agent. + */ + async ensureSession( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId?: string, + ): Promise { + let creation = this.creations.get(sessionId) + if (creation === undefined) { + creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) + .catch((error: unknown) => { + const live = this.ctx.agents.get(sessionId) + if (live !== undefined) { + if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + return live + } + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + throw error + }) + .finally(() => { this.creations.delete(sessionId) }) + this.creations.set(sessionId, creation) + } + const agent = await creation + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session)) + if (agent.session.header.cwd !== cwd) { + throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd) + } + return agent + } + + /** + * Install or return the Session-local model selection used by prompt assembly. + * @param agent - live Agent that owns the selection. + * @returns the installed mutable selection reference. + */ + selectionFor(agent: Agent): InstalledSelection { + const installed = this.selections.get(agent) + if (installed !== undefined) return installed + let picked: AgentModelSelection | undefined + const defaultModel = this.ctx.agentDefaultModel + const selection: InstalledSelection = { + get current(): AgentModelSelection { + if (picked !== undefined) return picked + const logged = agent.session.requestHeader()?.config + if (logged === undefined) return defaultModel.currentSelection() + return { + provider: logged.provider, + model: logged.model, + ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }), + } + }, + set current(next: AgentModelSelection) { + picked = next + }, + assembled: undefined, + } + installModelSelection(agent.ctx, selection) + this.selections.set(agent, selection) + return selection + } + + /** + * Serialize image admission and model selection for one Agent. + * @param agent - live Agent that owns the serialization chain. + * @param operation - asynchronous operation admitted after prior work settles. + * @returns the operation result or rejection. + */ + serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { + const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation) + this.imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined)) + return result + } + + /** + * Resolve the preset id and pre-publication Agent setup for a create or resume. + * @param presetId - requested preset or the configured default when omitted. + * @returns the resolved preset identity and Agent setup callback. + */ + async composeAgent(presetId: string | undefined): Promise<{ + readonly agentPreset?: string + readonly setup: AgentSetup + }> { + const presets = this.ctx.get('agentPresets') + if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } } + const resolvedId = (await presets.resolve(presetId)).id + return { + agentPreset: resolvedId, + setup: async (agentCtx) => { + this.installSelection(agentCtx) + await presets.mount(agentCtx, resolvedId) + }, + } + } + + private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined { + const agent = this.ctx.agents.get(sessionId) + if (agent === undefined) return undefined + return hasApiSessionSubagentOwner(this.ctx, agent.session, agent) + ? { error: apiSessionSubagentOwnershipError(sessionId) } + : { agent } + } + + private async resume(sessionId: SessionId): Promise { + const inspected = await inspectApiSession(this.ctx, sessionId) + if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + const composition = await this.composeAgent(resolveSessionPreset({ + header: inspected.meta, + events: inspected.events, + })) + const published = this.ctx.sessions.get(sessionId) + const live = this.ctx.agents.get(sessionId) + if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + return (await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: composition.setup, + })).agent + } + + private async createOrAdopt( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId: string | undefined, + ): Promise { + const attached = this.ctx.sessions.get(sessionId) + const live = this.ctx.agents.get(sessionId) + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (live !== undefined) return live + + const persistence = checkPersistedIdentity ? this.ctx.get('sessionPersistence') : undefined + const stored = persistence === undefined + ? undefined + : (await persistence.list()).find(header => header.id === sessionId) + if (persistence !== undefined && stored !== undefined) { + const inspected = await persistence.inspect(sessionId) + if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (inspected.meta.cwd !== cwd) { + throw new ApiSessionCwdConflict(sessionId, cwd, inspected.meta.cwd) + } + const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events }) + this.assertPresetUnchanged(sessionId, presetId, storedPreset) + const composition = await this.composeAgent(storedPreset) + return (await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: composition.setup, + })).agent + } + + try { + await mkdir(cwd, { recursive: true }) + } catch (error: unknown) { + throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) + } + const composition = await this.composeAgent(presetId) + return (await this.ctx.agents.create({ + sessionId, + agentOptions: this.agentOptions(), + meta: { + cwd, + ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }), + }, + setup: composition.setup, + })).agent + } + + private agentOptions(): AgentOptions { + const { provider, model } = this.ctx.agentDefaultModel.currentSelection() + return { provider, model } + } + + private installSelection(agentCtx: Context): void { + const agent = agentCtx.agent + if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent') + this.selectionFor(agent) + } + + private assertPresetUnchanged( + sessionId: SessionId, + requested: string | undefined, + existing: string | undefined, + ): void { + if (requested === undefined || requested === existing) return + throw new ApiSessionPresetConflict(sessionId, requested, existing) + } +} diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts new file mode 100644 index 0000000000..0f91bfaed6 --- /dev/null +++ b/packages/api/session-controller/src/catalog.ts @@ -0,0 +1,63 @@ +/** Shared projection of the live LLM registry into the browser model catalog. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { + ModelCatalogFailure, + ModelProviderGroup, + ModelReasoning, +} from './types.ts' + +/** + * Build the browser model catalog without requiring a Session. + * @param ctx - Host context carrying the live LLM registry. + * @returns successful non-empty provider groups and isolated provider failures. + */ +export async function buildModelCatalog(ctx: Context): Promise<{ + readonly groups: ModelProviderGroup[] + readonly failures: ModelCatalogFailure[] +}> { + const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { + try { + const models = await ctx.llm.listModels(provider.id) + const entries = await Promise.all(models.map(async (model) => { + const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id) + const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined + ? undefined + : { + efforts: resolved.reasoning.efforts.map(effort => ({ + id: effort.id, + name: effort.name, + ...(effort.description === undefined ? {} : { description: effort.description }), + })), + ...(resolved.reasoning.defaultEffort === undefined + ? {} + : { defaultEffort: resolved.reasoning.defaultEffort }), + } + return { + id: model.id, + name: model.name, + ...(model.description === undefined ? {} : { description: model.description }), + ...(reasoning === undefined ? {} : { reasoning }), + } + })) + return { + kind: 'group' as const, + group: { id: provider.id, name: provider.name, models: entries }, + } + } catch (error) { + return { + kind: 'failure' as const, + failure: { + id: provider.id, + name: provider.name, + message: error instanceof Error ? error.message : String(error), + }, + } + } + })) + return { + groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) + .filter(group => group.models.length > 0), + failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), + } +} diff --git a/packages/api/session-controller/src/client/contract/events.ts b/packages/api/session-controller/src/client/contract/events.ts new file mode 100644 index 0000000000..2f8bc48f82 --- /dev/null +++ b/packages/api/session-controller/src/client/contract/events.ts @@ -0,0 +1,146 @@ +/** Observable contiguous Session event window consumed by domain assemblers. */ +import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionEventEntry } from '../../types.ts' + +interface EventWindowLeaf { + readonly kind: 'leaf' + readonly entries: readonly SessionEventEntry[] + readonly length: number +} + +interface EventWindowConcat { + readonly kind: 'concat' + readonly left: EventWindowNode + readonly right: EventWindowNode + readonly length: number +} + +type EventWindowNode = EventWindowLeaf | EventWindowConcat + +function leaf(entries: readonly SessionEventEntry[]): EventWindowLeaf { + return { kind: 'leaf', entries, length: entries.length } +} + +function concat(left: EventWindowNode, right: EventWindowNode): EventWindowConcat { + return { kind: 'concat', left, right, length: left.length + right.length } +} + +function materialize(node: EventWindowNode): readonly SessionEventEntry[] { + if (node.kind === 'leaf') return node.entries + const entries = new Array(node.length) + const pending: EventWindowNode[] = [node] + let index = 0 + while (pending.length > 0) { + const current = pending.pop() as EventWindowNode + if (current.kind === 'concat') { + pending.push(current.right, current.left) + continue + } + for (const entry of current.entries) { + entries[index] = entry + index += 1 + } + } + return entries +} + +function windowSnapshot( + node: EventWindowNode, + hasMore: boolean, + revision: number, + change: SessionEventChange, +): SessionEventWindow { + let entries: readonly SessionEventEntry[] | undefined + return { + get entries() { + entries ??= materialize(node) + return entries + }, + hasMore, + revision, + change, + } +} + +/** Exact delta that produced the latest event-window revision. */ +export type SessionEventChange = + | { readonly kind: 'replace'; readonly entries: readonly SessionEventEntry[] } + | { readonly kind: 'prepend'; readonly entries: readonly SessionEventEntry[] } + | { readonly kind: 'append'; readonly entries: readonly SessionEventEntry[] } + +/** Current contiguous event window and its latest synchronous delta. */ +export interface SessionEventWindow { + readonly entries: readonly SessionEventEntry[] + readonly hasMore: boolean + readonly revision: number + readonly change: SessionEventChange +} + +/** Conversation-facing event source exposed by one Session binding. */ +export type SessionEventSource = ObservableSnapshot + +/** Session-owned event feed; every accepted window mutation publishes synchronously. */ +export class MutableSessionEventSource implements SessionEventSource { + private readonly listeners = new Set<() => void>() + private window: EventWindowNode = leaf([]) + private snapshot: SessionEventWindow = windowSnapshot( + this.window, + false, + 0, + { kind: 'replace', entries: [] }, + ) + + /** @returns the cached event-window snapshot. */ + getSnapshot(): SessionEventWindow { return this.snapshot } + + /** + * Subscribe to synchronous window publication. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Replace the complete contiguous window. + * @param entries - complete window. + * @param hasMore - whether older history remains. + */ + replace(entries: readonly SessionEventEntry[], hasMore: boolean): void { + this.window = leaf(entries) + this.publish(hasMore, { kind: 'replace', entries }) + } + + /** + * Prepend one older contiguous page. + * @param entries - newly loaded older entries. + * @param hasMore - whether still older history remains. + */ + prepend(entries: readonly SessionEventEntry[], hasMore: boolean): void { + this.window = concat(leaf(entries), this.window) + this.publish(hasMore, { kind: 'prepend', entries }) + } + + /** + * Append one contiguous live entry. + * @param entry - live tail entry. + */ + append(entry: SessionEventEntry): void { + const entries = [entry] + this.window = concat(this.window, leaf(entries)) + this.publish(this.snapshot.hasMore, { + kind: 'append', + entries, + }) + } + + private publish( + hasMore: boolean, + change: SessionEventChange, + ): void { + this.snapshot = windowSnapshot(this.window, hasMore, this.snapshot.revision + 1, change) + notifySubscribers(this.listeners, '[session-controller] event feed') + } +} diff --git a/packages/api/session-controller/src/client/contract/result.ts b/packages/api/session-controller/src/client/contract/result.ts new file mode 100644 index 0000000000..6577b63823 --- /dev/null +++ b/packages/api/session-controller/src/client/contract/result.ts @@ -0,0 +1,28 @@ +/** Client operation results spanning Session Remote calls and the legacy subagent carrier. */ + +import type { RpcError } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionError } from '../../types.ts' + +/** Failure surfaced by the Client Session object layer. */ +export type ClientFailure = RpcError | SessionError + +/** Success or failure returned by a Client Session operation. */ +export type ClientResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ClientFailure } + +/** + * Fold a rejected carrier operation into the Client Session failure vocabulary. + * @param error - rejection from a legacy subagent or local carrier call. + * @returns the failure branch of a Client Session result. + */ +export function transportResult(error: unknown): ClientResult { + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } +} diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts similarity index 76% rename from packages/client/runtime/src/client/contract/session.ts rename to packages/api/session-controller/src/client/contract/session.ts index e07267d487..6ac4182bb9 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -1,19 +1,20 @@ /** * The outward session face. Feature packages never see the concrete Session - * class: components read conversation state through `useSession` (the + * class: components read lifecycle state through `useSession` (the * ObservableSnapshot half), and orchestration code calls the behavior verbs * below — nothing else. Widening this interface is the explicit act of * widening what features may do to a session (and what every test fixture - * must stub); runtime-internal entry points (history staging, wire-frame + * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { - MessageId, PromptContentPart, QueueAction, RpcResult, SessionId, -} from '@deepseek-ai/dsh-api-remotes/client' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import type { ConversationSnapshot } from '../sessions/conversation.ts' -import type { ObservableSnapshot } from './store.ts' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { PromptContentPart, QueueAction } from '../../types.ts' +import type { ClientResult } from './result.ts' +import type { SessionSnapshot } from './snapshot.ts' /** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */ export interface ProjectionsFace { @@ -42,7 +43,7 @@ export interface ISession { content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, - ): Promise> + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. @@ -50,27 +51,27 @@ export interface ISession { */ readAttachment( attachmentId: AttachmentIdType, - ): Promise> + ): Promise> /** * Apply one edit, remove, or strict steer action to a still-pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. * @param action - requested queue operation. * @returns acceptance, or a business/transport error. */ - updateQueue(itemId: MessageId, action: QueueAction): Promise> + updateQueue(itemId: MessageId, action: QueueAction): Promise> /** * Cancel the running turn. Pending queued work remains and resumes in FIFO * order after the Host reaches cancellation quiescence. * @returns acceptance, or the business error. */ - cancel(): Promise> + cancel(): Promise> /** * Rename this session (explicit user title; pins it against automatic * regeneration). * @param title - raw title text (the host normalizes acceptance). * @returns the normalized accepted title and its event seq, or the business error. */ - rename(title: string): Promise> + rename(title: string): Promise> /** * Extend the history window backwards (older messages pagination). * @returns completion; failures land in snapshot.openState/loadingOlder. @@ -86,8 +87,8 @@ export interface ISession { } /** - * The full outward face: behavior verbs plus the conversation read side + * The full outward face: behavior verbs plus the Session lifecycle read side * (the `useSession` hook source). This is the type carried by * `SessionBinding.session` and the provide channel. */ -export type SessionFace = ISession & ObservableSnapshot +export type SessionFace = ISession & ObservableSnapshot diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts similarity index 76% rename from packages/client/runtime/src/client/contract/sessions.ts rename to packages/api/session-controller/src/client/contract/sessions.ts index 27b9d2d13b..4e64574681 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -1,39 +1,42 @@ /** * The outward sessions-service face — what `ctx.sessions` exposes to feature - * packages and the renderer host, and therefore exactly what the test - * runtime's sessions double must implement. Wire-pump entry points - * (handleMuxEnvelope/handleConnected/refresh) and runtime internals stay on - * the concrete class; cross-domain consumers keep the narrower - * [SessionsPort](./sessions-port.ts). Widening this interface is the + * packages. Transport entry points and implementation internals stay on + * the concrete class. Widening this interface is the * explicit act of widening what features may do to the sessions domain. */ import type { Context } from '@deepseek-ai/cordis' -import type { - RpcResult, SessionId, SubagentAddress, -} from '@deepseek-ai/dsh-api-remotes/client' -import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' -import type { AgentContext } from '../agents/scope.ts' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { AgentContext } from '../scope.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts' -import type { - SessionBinding, SessionListState, SessionProvideDescriptor, -} from '../sessions/service.ts' +import type { SessionBinding, SessionListState } from '../sessions/service.ts' +import type { ClientResult } from './result.ts' import type { SessionFace } from './session.ts' -import type { ObservableSnapshot } from './store.ts' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -export type { AgentContext } from '../agents/scope.ts' +export type { AgentContext } from '../scope.ts' /** The sessions-service face injected as `ctx.sessions`. */ export interface ISessions { /** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */ readonly list: ObservableSnapshot - /** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */ - readonly currentProvideInfo: HostObservable /** * The `session.search` result bound the wire schema fixes, exposed to * presentation as injected data. Not per-connection state: every transport * (fixture included) reports the same number. */ readonly searchResultLimit: number + /** + * Create or adopt a Session on the Host. + * @param opts - target workspace, directory, and optional preallocated identity. + * @returns the Session identity after its local binding is addressable. + */ + create(opts?: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + }): Promise /** * Select a session as current. * @param id - session id (must exist in the list; unknown ids fail loud). @@ -73,6 +76,8 @@ export interface ISessions { noteAgentPreset(sessionId: SessionId, agentPreset: string): void /** Clear the current selection into the no-session view state. */ clear(): void + /** @returns completion of the current or newly started Session-list refresh. */ + refresh(): Promise /** * Search the Host's visible message-content index. Results stay * request-local; the list snapshot remains the metadata authority. @@ -83,7 +88,7 @@ export interface ISessions { search( query: string, signal: AbortSignal, - ): Promise> + ): Promise> /** * Fork a session from a completed-turn prefix of the source; on resolution * the child is in the list store and `open()` can target it. @@ -95,13 +100,6 @@ export interface ISessions { * @throws when the fork fails, or when a requested child-title rename fails after creation. */ fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise - /** - * Register a per-session standard-props provider (hooks become `use` - * selector hooks on the render side; props spread verbatim). - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider. - */ - provide(descriptor: SessionProvideDescriptor): () => void /** * Resolve an Agent-scoped context view (use-and-discard). * @param id - session id. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts new file mode 100644 index 0000000000..7304c21bb8 --- /dev/null +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -0,0 +1,45 @@ +/** Session-owned observable state excluding Conversation target data. */ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientFailure } from './result.ts' + +/** One transient inbox occurrence from the authoritative queue snapshot. */ +export interface QueuedMessage { + readonly id: MessageId + readonly messageId: MessageId + readonly placement: 'queued' | 'steering' | 'context' + readonly content: readonly ContentBlock[] + readonly preview: string + readonly text: string | null +} + +/** History-open lifecycle of a Session event window. */ +export type OpenState = 'cold' | 'loading' | 'open' | 'error' + +/** Send/stop failure surfaced by Session consumers. */ +export interface PromptError { + readonly op: 'send' | 'stop' + readonly error: ClientFailure +} + +/** Immutable Session lifecycle and control snapshot. */ +export interface SessionSnapshot { + readonly sessionId: SessionId + readonly queue: readonly QueuedMessage[] + readonly running: boolean + readonly subagent: { readonly address: SubagentAddress; readonly parentAvailable: boolean } | null + readonly removed: boolean + readonly openState: OpenState + readonly openError: ClientFailure | null + readonly hasMore: boolean + readonly loadingOlder: boolean + readonly promptError: PromptError | null + readonly blank: boolean + readonly lastAgentError: string | null + /** A prompt call has begun on this Client Session object. */ + readonly promptAttempted: boolean + /** The first accepted prompt has not reached a durable `turn/start` event. */ + readonly awaitingFirstTurn: boolean +} diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts new file mode 100644 index 0000000000..acbc81cae3 --- /dev/null +++ b/packages/api/session-controller/src/client/index.ts @@ -0,0 +1,125 @@ +/** Client Session object layer, Agent scopes, and Remote lifecycle wiring. */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent/types' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { createSessionControlStream } from './transport.ts' +import { ClientSessions } from './sessions/service.ts' +import type { ISessions } from './contract/sessions.ts' +import type { SessionRemotes } from './sessions/remotes.ts' +import type {} from '../remote-events.ts' + +export { + createSessionControlStream, + SessionEventStream, + SESSION_SEARCH_RESULT_LIMIT, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, + sessionStreamFailure, +} from './transport.ts' +export type { + ClientSessionPageRequest, + SessionControlStream, + SessionControlStreamOptions, + SessionEventStreamOptions, + SessionJournalChange, + SessionRemote, +} from './transport.ts' +export { createScope, scopeOf } from './scope.ts' +export type { AgentContext, AgentScopeHandle } from './scope.ts' +export { SessionCreateError, SessionForkError, workspaceTitleOf } from './sessions/service.ts' +export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' +export type { + SessionListPhase, + SessionListSnapshot, + SessionSearchResultItem, + SubagentCatalogSnapshot, +} from './sessions/manager.ts' +export type { Session } from './sessions/session.ts' +export type { + ProjectionsBaseline, + ProjectionValueStore, + SessionProjectionMap, + UseProjection, +} from './sessions/projection-store.ts' +export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' +export type { ISessions } from './contract/sessions.ts' +export { MutableSessionEventSource } from './contract/events.ts' +export type { SessionEventChange, SessionEventSource, SessionEventWindow } from './contract/events.ts' +export type { + OpenState, + PromptError, + QueuedMessage, + SessionSnapshot, +} from './contract/snapshot.ts' +export type { ClientFailure, ClientResult } from './contract/result.ts' +export { indexSubagentDescendants } from './sessions/subagent-lineage.ts' +export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts' + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * A Host connection generation completed its readiness handshake. + * @mode emit + */ + 'connection/reset'(): void + } + + interface Context { + /** Client Session object layer and Agent scope owner. */ + sessions: import('./contract/sessions.ts').ISessions + } +} + +/** Required wire, Remote, and Context projection services. */ +export const inject = [ + 'connection', + 'typert', + 'remote', + 'remote.commands', + 'remote.session', +] + +/** + * Resolve the Client Session service from any Client Cordis context. + * @param ctx - Client root or Agent-scoped context. + * @returns the Client Session object layer. + */ +export function resolveClientSessions(ctx: Context): ISessions { + const sessions = ctx.get('sessions') + if (sessions === undefined) throw new Error('session-controller: Client sessions service unavailable') + return sessions +} + +/** + * Install Client Session state and its reconnecting control stream. + * @param ctx - Client Cordis context. + */ +export function apply(ctx: Context): void { + const connection = ctx.get('connection') as ConnectionHandle + const remotes = ctx.remote as unknown as SessionRemotes + const sessions = new ClientSessions(ctx, connection.api, remotes) + ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) + ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) + ctx.remote.$on('api-session/status', (sessionId, running) => { + sessions.handleSessionStatus(sessionId, running) + }) + ctx.remote.$on('api-session/activity', (sessionId, updatedAt) => { + sessions.handleSessionActivity(sessionId, updatedAt) + }) + ctx.remote.$on('api-session/error', (sessionId, message) => { + sessions.handleSessionError(sessionId, message) + }) + + const control = createSessionControlStream(remotes, { + accept: (frame) => { sessions.handleControlFrame(frame) }, + failed: (error) => { console.error('[session-controller] control stream failed:', error) }, + }) + control.start() + ctx.on('connection/reset', () => { sessions.handleConnected() }) + if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => sessions.scopeOf(candidate), + resolve: sessionId => sessions.resolveAgentScope(sessionId), + }) + ctx.effect(() => async () => { await control.dispose() }, 'session-controller.client.control') +} diff --git a/packages/client/runtime/src/client/ordered-baseline.ts b/packages/api/session-controller/src/client/ordered-baseline.ts similarity index 100% rename from packages/client/runtime/src/client/ordered-baseline.ts rename to packages/api/session-controller/src/client/ordered-baseline.ts diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/api/session-controller/src/client/scope.ts similarity index 91% rename from packages/client/runtime/src/client/agents/scope.ts rename to packages/api/session-controller/src/client/scope.ts index b1078e71ca..5e1dca62a3 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/api/session-controller/src/client/scope.ts @@ -17,12 +17,13 @@ */ import { Context as CordisContext } from '@deepseek-ai/cordis' import type { Context, Fiber } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import type { TypertClientRemote, TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol' +import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol' /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ export type AgentContext = Omit & { - readonly remote: TypertClientRemote & TypertRemoteScopeApi<'agent'> + readonly remote: ClientRemote & TypertRemoteScopeApi<'agent'> } /** Context tag written by {@link createScope}. */ diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts similarity index 79% rename from packages/client/runtime/src/client/sessions/lineage.ts rename to packages/api/session-controller/src/client/sessions/lineage.ts index 7579310f49..3f6e21dbc9 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -2,18 +2,18 @@ // The input order is authoritative; lineage only makes each child adjacent to its parent. // Orphaned lineage degrades to root level; cycles fail soft and emit as roots. -import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { PendingInteractionStatus } from './pending.ts' +import type { SessionSummary } from '../../types.ts' -/** Host list summary enriched with the latest mux-projected durable title. */ +/** Host list summary enriched with the latest Session Controller title projection. */ export interface TitledSessionSummary extends SessionSummary { title?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> } -/** One flattened session-list row with lineage depth and live pending interaction. */ +/** One flattened session-list row with lineage depth. */ export interface SessionListEntry { sessionId: SessionId title?: string @@ -29,8 +29,6 @@ export interface SessionListEntry { agentPreset?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> - /** User interaction currently blocking this session, derived from live mux frames. */ - pendingInteraction?: PendingInteractionStatus /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */ completed: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ @@ -42,13 +40,11 @@ export interface SessionListEntry { * follows the established input order; this projection never re-sorts a * hydrated list from mutable timestamps. * @param summaries - the host's session.list items. - * @param pendingInteractions - current manager-owned interaction status by session. * @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false). * @returns display rows in render order. */ export function flattenLineage( summaries: readonly TitledSessionSummary[], - pendingInteractions?: ReadonlyMap, completed?: ReadonlySet, ): SessionListEntry[] { const byId = new Map() @@ -70,14 +66,12 @@ export function flattenLineage( const visited = new Set() const walk = (s: TitledSessionSummary, depth: number): void => { if (visited.has(s.sessionId)) { - console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`) + console.warn(`[session-controller] lineage cycle at ${s.sessionId}; emitting as root`) return } visited.add(s.sessionId) - const pendingInteraction = pendingInteractions?.get(s.sessionId) out.push({ ...s, - ...(pendingInteraction === undefined ? {} : { pendingInteraction }), completed: completed?.has(s.sessionId) ?? false, depth, }) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts similarity index 66% rename from packages/client/runtime/src/client/sessions/manager.ts rename to packages/api/session-controller/src/client/sessions/manager.ts index 13aa20d1c8..ddfae1d9e2 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -1,19 +1,25 @@ // SessionManager: the instance cluster Map (lazy-built, resident) + the frame -// dispatch entry + list state, constructed and held by SessionRuntime (one per client runtime). +// dispatch entry + list state, constructed and held by ClientSessions (one per browser client). // List data never enters zustand; React connects via subscribe/getListSnapshot. import type { - IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, - SessionSummary, SubagentAddress, SubagentCatalog, JobView, WorkspaceId, -} from '@deepseek-ai/dsh-api-remotes/client' -// Value import from the inline-safe wire layer (not the connection plugin): -// plugin-to-plugin value imports are a bundle purity error. -import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' + IApiClient, SubagentAddress, SubagentCatalog, +} from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { + SessionControlBaseline, + SessionControlFrame, + SessionQueuedItem, + SessionError, + SessionSummary, + SessionJob as JobView, +} from '../../types.ts' import { mergeOrderedBaseline } from '../ordered-baseline.ts' -import type { ConversationRuntime } from './conversation-assembler.ts' +import type { ClientFailure, ClientResult } from '../contract/result.ts' +import { transportResult } from '../contract/result.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' -import type { PendingInteractionStatus } from './pending.ts' // Type-only merge edge: the title domain's client-namespace outlet declares // the 'title' projection key this manager projects into list rows (and any // useProjection('title') consumer reads). Zero value imports by construction. @@ -47,7 +53,7 @@ export interface SessionListSnapshot { state: 'idle' | 'loading' | 'error' /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ phase: SessionListPhase - error: RpcError | null + error: ClientFailure | null subagentsByParent: Readonly> /** Background jobs per session; an absent key is an empty set. */ jobsBySession: Readonly> @@ -57,7 +63,7 @@ export interface SessionListSnapshot { /** One parent-addressed durable catalog projected through the sessions snapshot. */ export interface SubagentCatalogSnapshot extends SubagentCatalog { state: 'loading' | 'ready' | 'error' - error: RpcError | null + error: ClientFailure | null } interface CatalogInflight { @@ -76,44 +82,13 @@ type SessionListMutation = /** Local first-send flip: the sender clears blank without waiting for a host frame. */ | { kind: 'engaged'; sessionId: SessionId } -/** Stable identity of a frame retained until an uninstantiated Session can consume it. */ -function bufferedRequestKey(envelope: RpcRequest): string | undefined { - const frame = envelope.payload - switch (frame.type) { - case 'approval/requested': return `a:${frame.approvalId}` - case 'question/requested': return `q:${envelope.rpcId}` - case 'session/queue': return 'queue' - /* v8 ignore next -- pendingBuffers contains only the three frame types above. */ - default: return undefined - } -} - -/** Match ui-user-questions's binary plan-review routing at the wire boundary. */ -function questionInteractionStatus( - questions: Extract['questions'], -): PendingInteractionStatus { - if (questions.length !== 1) return 'question' - const question = questions[0] as typeof questions[number] - const intent = question.intent - if (intent?.kind !== 'plan-review' || question.detail === undefined) return 'question' - if (question.multiSelect === true) return 'question' - const options = question.options ?? [] - if (options.length > 2) return 'question' - return options.some(option => option.label === intent.approve) ? 'plan-review' : 'question' -} - /** Instance cluster + frame entry + the session list. */ export class SessionManager { private readonly sessions = new Map() - /** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history - * cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates - * compact by identity. Instantiation replays and clears it, while removal drops it. */ - private readonly pendingBuffers = new Map[]>() - /** Outstanding answerable interactions per session, keyed by their stable request identity. - * Manager-owned rather than read off Session instances because the sidebar must light up for - * sessions never instantiated. Cleared per connection generation — the reopen replay re-adds - * still-pending requests — and on session-removed. */ - private readonly pendingInteractions = new Map>() + /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ + private readonly sessionDisposals = new Set>() + /** Latest transient queues, retained independently of Session object materialization. */ + private readonly queues = new Map() /** * Sessions that finished running while not selected — the sidebar's green * "done" reminder (manager-owned, survives connection generations; cleared @@ -131,7 +106,7 @@ export class SessionManager { private listState: 'idle' | 'loading' | 'error' = 'idle' /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */ private listPhase: SessionListPhase = 'pending' - private listError: RpcError | null = null + private listError: ClientFailure | null = null private listInflight: Promise | null = null /** Mutations arriving after a list request starts are replayed over its response. */ private listMutations: SessionListMutation[] | null = null @@ -143,8 +118,9 @@ export class SessionManager { private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() /** - * Background jobs per session, last-wins from `session/jobs`. An empty set - * is stored as an absent key, so absence and `[]` are one representation. + * Background jobs per session, last-wins from Session Controller's control + * stream. An empty set is stored as an absent key, so absence and `[]` are + * one representation. */ private readonly jobsBySession = new Map() @@ -169,7 +145,6 @@ export class SessionManager { private readonly remote: SessionRemotes, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, - private readonly conversation?: ConversationRuntime, ) { this.selected = restoredSelection if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress) @@ -259,8 +234,41 @@ export class SessionManager { * truth — a later get() lazily rebuilds and open() backfills history. * @param sessionId - the session to drop. */ - drop(sessionId: SessionId): void { + async drop(sessionId: SessionId): Promise { + const session = this.sessions.get(sessionId) this.sessions.delete(sessionId) + if (session !== undefined) await this.startSessionDisposal(session) + } + + /** + * Stop owned timers and every remaining Session instance. + * @returns when every Session Remote iterator has completed teardown. + */ + async dispose(): Promise { + for (const timer of this.catalogDebounce.values()) clearTimeout(timer) + this.catalogDebounce.clear() + this.catalogStale.clear() + this.openCatalogs.clear() + const sessions = [...this.sessions.values()] + this.sessions.clear() + for (const session of sessions) void this.startSessionDisposal(session) + await this.drainSessionDisposals() + } + + private startSessionDisposal(session: Session): Promise { + const disposal = session.dispose() + this.sessionDisposals.add(disposal) + void disposal.then( + () => { this.sessionDisposals.delete(disposal) }, + () => { this.sessionDisposals.delete(disposal) }, + ) + return disposal + } + + private async drainSessionDisposals(): Promise { + while (this.sessionDisposals.size > 0) { + await Promise.allSettled([...this.sessionDisposals]) + } } /** @@ -274,16 +282,11 @@ export class SessionManager { if (session === undefined) { session = this.createSession(sessionId) this.sessions.set(sessionId, session) - // Replay approval/question/queued frames buffered before instantiation (rpcId - // verbatim, same semantics as the subscribed baseline replay). Replay happens - // BEFORE the running-bit sync: a not-running summary must sweep replayed queue + // Install the latest control baseline before the running-bit sync: a + // not-running summary must sweep replayed queue // rows the same way a live status flip would (their retirement events dropped // while the session was uninstantiated). - const buffered = this.pendingBuffers.get(sessionId) - if (buffered !== undefined) { - this.pendingBuffers.delete(sessionId) - for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload) - } + session.replaceControl(this.queues.get(sessionId) ?? []) // Sync the running and blank bits from the list snapshot into the new // instance (consistency when the list precedes open). const summary = this.summaries.find(s => s.sessionId === sessionId) @@ -318,15 +321,9 @@ export class SessionManager { this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, projections: this.projectionStore(sessionId), - ...this.conversation === undefined ? {} : { conversation: this.conversation }, }) } - /** Rebuild every resident Session after one coalesced registry transaction. */ - rebuildConversationRegistry(): void { - for (const session of this.sessions.values()) session.rebuildConversationRegistry() - } - /** Resident per-session projection store (create-on-demand; outlives instantiation). */ private projectionStore(sessionId: SessionId): ProjectionValueStore { let store = this.projectionStores.get(sessionId) @@ -386,7 +383,7 @@ export class SessionManager { }) } } catch (error: unknown) { - const folded = transportError(error) + const folded = transportResult(error) this.catalogs.set(parentSessionId, { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, @@ -446,10 +443,10 @@ export class SessionManager { this.notifier.markDirty() this.listInflight = (async () => { try { - const { result } = await this.api.sessions.list({}) + const result = toSessionResult(await this.remote.session.list({})) if (result.ok) { - const baseline = this.listPhase === 'pending' - ? result.value.items + const baseline: SessionSummary[] = this.listPhase === 'pending' + ? [...result.value.items] : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) // Seed first observations from the pull-time baseline BEFORE replaying // in-flight mutations, then reconcile the reminders after EVERY @@ -496,8 +493,8 @@ export class SessionManager { } } catch (error) { this.listState = 'error' - const folded = transportError(error) - /* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */ + const folded = transportResult(error) + /* v8 ignore next -- the `? null` arm is unreachable: transportResult always returns ok:false. */ this.listError = folded.ok ? null : folded.error } finally { this.listMutations = null @@ -518,11 +515,19 @@ export class SessionManager { async search( query: string, signal: AbortSignal, - ): Promise> { + ): Promise> { try { - return (await this.api.sessions.search({ query }, signal)).result + const result = toSessionResult(await this.remote.session.search({ query }, signal)) + if (!result.ok) return result + return { + ok: true, + value: { + items: [...result.value.items], + hasMore: result.value.hasMore, + }, + } } catch (error: unknown) { - return transportError(error) + return transportResult(error) } } @@ -532,16 +537,20 @@ export class SessionManager { * (entity birth precedes the first message). * @param opts - target workspace or working directory, plus an optional caller-owned id. * @returns the create result. - */ + */ async create( - opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, - ): Promise> { + opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + } = {}, + ): Promise> { try { const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } - const { result } = await this.api.sessions.create(payload) + const result = toSessionResult(await this.remote.session.create(payload)) if (result.ok) { this.recordMutation({ kind: 'upsert', summary: { sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, @@ -564,7 +573,7 @@ export class SessionManager { } return result } catch (error) { - return transportError(error) + return transportResult(error) } } @@ -579,13 +588,13 @@ export class SessionManager { */ async fork( opts: { sessionId: SessionId; atSeq?: number }, - ): Promise> { + ): Promise> { try { const source = this.summaries.find(s => s.sessionId === opts.sessionId) - const { result } = await this.api.sessions.fork({ + const result = toSessionResult(await this.remote.session.fork({ sessionId: opts.sessionId, ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, - }) + })) const childId = result.ok ? result.value.sessionId : workspaceAttachSessionId(result.error) @@ -598,7 +607,7 @@ export class SessionManager { } return result } catch (error) { - return transportError(error) + return transportResult(error) } } @@ -652,260 +661,148 @@ export class SessionManager { return this.listSnapshotCache } - /** Add or refresh one stable pending-interaction identity. */ - private trackPending(sessionId: SessionId, key: string, status: PendingInteractionStatus): void { - let interactions = this.pendingInteractions.get(sessionId) - if (interactions === undefined) { - interactions = new Map() - this.pendingInteractions.set(sessionId, interactions) - } - if (interactions.get(key) === status) return - interactions.set(key, status) - this.notifier.markDirty() - } - - /** Settle one pending-interaction identity without disturbing sibling waits. */ - private resolvePending(sessionId: SessionId, key: string): void { - const interactions = this.pendingInteractions.get(sessionId) - if (interactions === undefined || !interactions.delete(key)) return - if (interactions.size === 0) this.pendingInteractions.delete(sessionId) - this.notifier.markDirty() - } - - // ---- ConnectionController sinks (wired by boot) ---- + // ---- Live control and Host-event sinks ---- /** - * Mux frame entry: sessionId-bearing frames go only to instantiated sessions - * (no lazy build; non-pending frames for uninstantiated sessions drop — - * history backfills them on open). - * @param envelope - the frame with its wire rpcId. + * Apply a complete control baseline or one later replacement frame. + * @param frame - baseline or live control replacement from Session Controller. */ - handleMuxEnvelope(envelope: RpcRequest): void { - const frame = envelope.payload - if (frame.type === 'stream/error') return // Controller already treats this as stream failure - if ( - frame.type === 'session/event' - && frame.event.type === 'user/message' - && frame.event.data.source.kind === 'user' - ) { - // session.list supplies the cold baseline, while a direct prompt or an - // admitted steer advances it between pulls. Max keeps replayed or - // repaired older user messages from moving the row backwards. - this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time }) + handleControlFrame(frame: SessionControlFrame): void { + if (frame.type === 'baseline') { + this.replaceControlBaseline(frame.value) + return } - if (frame.type === 'session/projection') { - // Finished host-computed value: land it in the resident store whether or - // not the Session is instantiated (list rows read the 'title' key). The - // synchronous markDirty keeps the list snapshot same-tick fresh (the - // store's own any-key channel is microtask-batched). + if (frame.type === 'projection') { this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq) this.notifier.markDirty() return } - if (frame.type === 'session/jobs') { - // Whole-set snapshot, so last-wins with no reconciliation. The Host omits - // the baseline for an empty set, which is the same fact an emptying change - // reports as `[]` — both land as an absent key. + if (frame.type === 'jobs') { if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) else this.jobsBySession.set(frame.sessionId, frame.jobs) this.notifier.markDirty() return } - if (frame.type === 'session/subscribed') { - // Rows past the host's durable baseline rode state a restart lost; drop - // them so last-wins cannot pin a phantom value over recomputed truth. - this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) - // Same re-baseline reasoning as the queue below: this generation sends a - // task baseline only when the set is non-empty, so a mirror kept from the - // previous generation would survive as a phantom list. - this.jobsBySession.delete(frame.sessionId) - this.notifier.markDirty() - // New mux-generation baseline: discard the previous queue snapshot. - // The host omits session/queue when the live queue is empty, so retaining - // it could replay stale work when the Session is instantiated later. - // This is the same re-baseline signal Session uses for its own mirror. - const buffered = this.pendingBuffers.get(frame.sessionId) - if (buffered !== undefined) { - const kept = buffered.filter(item => item.payload.type !== 'session/queue') - if (kept.length !== buffered.length) { - if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId) - else this.pendingBuffers.set(frame.sessionId, kept) - } - } + this.queues.set(frame.sessionId, frame.items) + this.sessions.get(frame.sessionId)?.handleControlFrame(frame) + } + + private replaceControlBaseline(baseline: SessionControlBaseline): void { + this.queues.clear() + for (const [sessionId, items] of Object.entries(baseline.queues)) { + this.queues.set(sessionId as SessionId, items) } - // List-level pending-interaction status (the sidebar amber dot): tracked - // for every session, instantiated or not; stable keys make replays idempotent. - if (frame.type === 'approval/requested') { - this.trackPending(frame.sessionId, `a:${frame.approvalId}`, 'approval') - } else if (frame.type === 'approval/resolved') { - this.resolvePending(frame.sessionId, `a:${frame.approvalId}`) - } else if (frame.type === 'question/requested') { - this.trackPending( - frame.sessionId, - `q:${envelope.rpcId}`, - questionInteractionStatus(frame.questions), - ) - } else if (frame.type === 'question/resolved') { - this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`) + + this.jobsBySession.clear() + for (const [sessionId, jobs] of Object.entries(baseline.jobs)) { + if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) } - const session = this.sessions.get(frame.sessionId) - if (session === undefined) { - // Answerable requests never hit history: retain each live identity until - // instantiation, compacting replay duplicates and resolutions so list - // status cannot outlive the PendingWait the user would need to answer. - // Queue is a latest-value snapshot; everything else drops because open - // backfills it from history. - switch (frame.type) { - case 'approval/requested': - case 'question/requested': - case 'session/queue': { - const buffer = this.pendingBuffers.get(frame.sessionId) ?? [] - const key = frame.type === 'approval/requested' - ? `a:${frame.approvalId}` - : frame.type === 'question/requested' ? `q:${envelope.rpcId}` : 'queue' - const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) - if (prior === -1) buffer.push(envelope) - else buffer[prior] = envelope - this.pendingBuffers.set(frame.sessionId, buffer) - return - } - case 'approval/resolved': - case 'question/resolved': { - const buffer = this.pendingBuffers.get(frame.sessionId) - if (buffer === undefined) return - const key = frame.type === 'approval/resolved' - ? `a:${frame.approvalId}` - : `q:${frame.questionRpcId}` - const prior = buffer.findIndex(item => bufferedRequestKey(item) === key) - if (prior !== -1) buffer.splice(prior, 1) - if (buffer.length === 0) this.pendingBuffers.delete(frame.sessionId) - return - } - default: - return - } + + for (const [sessionId, block] of Object.entries(baseline.projections)) { + const store = this.projectionStore(sessionId as SessionId) + store.truncate(block.asOfSeq) + store.seed(block) } - session.handleMuxEnvelope(envelope.rpcId, frame) + for (const [sessionId, session] of this.sessions) { + session.replaceControl(this.queues.get(sessionId) ?? []) + } + this.notifier.markDirty() } /** - * Host frame entry: list upkeep + per-instance running/removed/agent-error relay. - * @param envelope - the frame with its wire rpcId. + * Apply one Session-list addition forwarded through `ctx.remote.$on`. + * @param summary - current Host summary for the added Session. */ - handleHostEnvelope(envelope: RpcRequest): void { - const frame = envelope.payload - switch (frame.type) { - case 'host/session-added': { - this.mergeSummary({ - sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank, - ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), - ...(frame.origin !== undefined ? { origin: frame.origin } : {}), - ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), - ...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}), - }) - this.sessions.get(frame.sessionId)?.handleBlank(frame.blank) - if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) { - this.markCatalogParentExpandable(frame.parentSessionId) - } - if (frame.parentSessionId !== undefined - && (this.selected === frame.parentSessionId || this.openCatalogs.has(frame.parentSessionId))) { - this.scheduleCatalogRefresh(frame.parentSessionId) - } - return + handleSessionAdded(summary: SessionSummary): void { + this.mergeSummary(summary) + this.sessions.get(summary.sessionId)?.handleBlank(summary.blank) + const projections = summary.projections + if (projections !== undefined) { + const store = this.projectionStore(summary.sessionId) + for (const [key, value] of Object.entries(projections.values)) { + store.apply(key, value, projections.asOfSeq) } - case 'host/session-removed': { - const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId) - const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId) - this.recordMutation(durableSubagent - ? { kind: 'status', sessionId: frame.sessionId, running: false } - : { kind: 'remove', sessionId: frame.sessionId }) - this.updateCatalogActivity(frame.sessionId, false) - if (durableSubagent) { - // An Activation detaching is not durable child deletion: - // keep its lineage and conversation while returning it to idle. - this.sessions.get(frame.sessionId)?.handleRunning(false) - } else { - this.sessions.get(frame.sessionId)?.handleRemoved() - } - this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation - this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone - // Owner disposal already dropped these registry-side, but that lands on - // the mux stream while this frame rides the host stream, so the two have - // no relative order. Clearing here makes a detached Activation's rows - // disappear whichever arrives first. - this.jobsBySession.delete(frame.sessionId) - if (!durableSubagent) this.projectionStores.delete(frame.sessionId) - // A pull already in flight was requested before this removal and can - // carry the pre-removal parentAvailable:true, which would resurrect - // the writable editor this invalidation just closed. Replay false over - // that response and queue one trailing refresh so the post-removal - // host truth converges. - const inflightCatalog = this.catalogInflight.get(frame.sessionId) - if (inflightCatalog !== undefined) { - inflightCatalog.parentAvailableOverride = false - this.catalogStale.add(frame.sessionId) - } - // The removed session can no longer be the delivery owner of its - // catalog: invalidate availability immediately. Removal schedules no - // catalog refresh, and without this an addressed child keeps a - // writable editor against a dead continuation owner until an - // unrelated refresh (or forever, for a closed menu). - const ownedCatalog = this.catalogs.get(frame.sessionId) - if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) { - this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false }) - } - for (const [childId, address] of this.addresses) { - if (address.parentSessionId !== frame.sessionId) continue - this.sessions.get(childId)?.handleSubagentParentAvailable(false) - } - return - } - case 'host/session-status': { - this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running }) - this.sessions.get(frame.sessionId)?.handleRunning(frame.running) - this.updateCatalogActivity(frame.sessionId, frame.running) - return - } - case 'host/agent-error': { - this.sessions.get(frame.sessionId)?.handleAgentError(frame.message) - return // not reflected in the list - } - default: - return // stream/error ignored; unknown frames ignored (documented default) + } + if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) { + this.markCatalogParentExpandable(summary.parentSessionId) + } + if (summary.parentSessionId !== undefined + && (this.selected === summary.parentSessionId || this.openCatalogs.has(summary.parentSessionId))) { + this.scheduleCatalogRefresh(summary.parentSessionId) } } /** - * The moment a connection generation dies (before any next-generation frame - * can arrive — onConnected waits for the readiness handshake while replayed - * frames flow from stream open, so clearing there would race the replay): - * drop generation-scoped live state. Interactions resolved while disconnected - * send no frame, so stale statuses and buffered answerable frames must not - * survive into the next generation — mux-open replay re-adds every still-pending - * request with its live rpcId. - */ - handleDisconnected(): void { - if (this.pendingInteractions.size > 0) { - this.pendingInteractions.clear() - this.notifier.markDirty() + * Apply one Session removal forwarded through `ctx.remote.$on`. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: SessionId): void { + const summary = this.summaries.find(candidate => candidate.sessionId === sessionId) + const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(sessionId) + this.recordMutation(durableSubagent + ? { kind: 'status', sessionId, running: false } + : { kind: 'remove', sessionId }) + this.updateCatalogActivity(sessionId, false) + if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false) + else this.sessions.get(sessionId)?.handleRemoved() + this.queues.delete(sessionId) + this.jobsBySession.delete(sessionId) + if (!durableSubagent) this.projectionStores.delete(sessionId) + const inflightCatalog = this.catalogInflight.get(sessionId) + if (inflightCatalog !== undefined) { + inflightCatalog.parentAvailableOverride = false + this.catalogStale.add(sessionId) } - for (const [sessionId, buffer] of [...this.pendingBuffers]) { - const kept = buffer.filter(item => - item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested') - if (kept.length === buffer.length) continue - if (kept.length === 0) this.pendingBuffers.delete(sessionId) - else this.pendingBuffers.set(sessionId, kept) + const ownedCatalog = this.catalogs.get(sessionId) + if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) { + this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false }) + } + for (const [childId, address] of this.addresses) { + if (address.parentSessionId === sessionId) { + this.sessions.get(childId)?.handleSubagentParentAvailable(false) + } } } - /** After each connection generation: refresh the session baseline and rebuild opened windows. */ + /** + * Apply one live Agent running-state change. + * @param sessionId - Session whose Agent state changed. + * @param running - current Agent running state. + */ + handleSessionStatus(sessionId: SessionId, running: boolean): void { + this.recordMutation({ kind: 'status', sessionId, running }) + this.sessions.get(sessionId)?.handleRunning(running) + this.updateCatalogActivity(sessionId, running) + } + + /** + * Advance Session-list activity from one user-authored durable message. + * @param sessionId - Session whose activity changed. + * @param updatedAt - durable message timestamp. + */ + handleSessionActivity(sessionId: SessionId, updatedAt: number): void { + this.recordMutation({ kind: 'activity', sessionId, updatedAt }) + } + + /** + * Surface one live Agent failure on an already-materialized Session. + * @param sessionId - Session whose Agent failed. + * @param message - caller-visible failure description. + */ + handleSessionError(sessionId: SessionId, message: string): void { + this.sessions.get(sessionId)?.handleAgentError(message) + } + + /** + * Repair one re-established Host-event generation with queryable baselines. + * Opened Session follow streams resume independently through API Gateway. + */ handleConnected(): void { void this.refreshList() const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected) if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId) if (this.selected !== undefined) void this.refreshSubagents(this.selected) for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId) - for (const session of this.sessions.values()) void session.resync() } /** Debounce membership refetches while one parent catalog is selected or open. */ @@ -1030,15 +927,7 @@ export class SessionManager { ...(projectionValues === undefined ? {} : { projectionValues }), } }) - const pendingInteractions = new Map() - for (const [sessionId, interactions] of this.pendingInteractions) { - const statuses = [...interactions.values()] - // The composer selects the first question ahead of approval. Mirror that - // answer order so the sidebar names the interaction the user can act on. - const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0] - if (status !== undefined) pendingInteractions.set(sessionId, status) - } - const fresh = flattenLineage(merged, pendingInteractions, this.completedNotifications) + const fresh = flattenLineage(merged, this.completedNotifications) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( @@ -1046,7 +935,6 @@ export class SessionManager { && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth - && prev.pendingInteraction === entry.pendingInteraction && prev.projectionValues === entry.projectionValues && prev.completed === entry.completed ) return prev @@ -1125,7 +1013,13 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi } /** Temporary source-plane bridge while the Host contract and client project build independently. */ -function workspaceAttachSessionId(error: RpcError): SessionId | undefined { - const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } } - return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined +function workspaceAttachSessionId(error: ClientFailure): SessionId | undefined { + return error.code === 'workspace-attach-failed' ? error.details.sessionId : undefined +} + +/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ +function toSessionResult( + result: import('@deepseek-ai/dsh-typert-protocol').RemoteResult, +): ClientResult { + return result.ok ? result : { ok: false, error: result.error as SessionError } } diff --git a/packages/client/runtime/src/client/sessions/notifier.ts b/packages/api/session-controller/src/client/sessions/notifier.ts similarity index 95% rename from packages/client/runtime/src/client/sessions/notifier.ts rename to packages/api/session-controller/src/client/sessions/notifier.ts index cc15beebd7..660c8645b4 100644 --- a/packages/client/runtime/src/client/sessions/notifier.ts +++ b/packages/api/session-controller/src/client/sessions/notifier.ts @@ -1,3 +1,5 @@ +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' + /** * Batches structural updates in microtasks and stream updates by animation * frame. Reads may rebuild a dirty snapshot without consuming the pending @@ -90,6 +92,6 @@ export class Notifier { this.dirty = false this.rebuild() } - for (const listener of this.listeners) listener() + notifySubscribers(this.listeners, '[session-controller]') } } diff --git a/packages/client/runtime/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts similarity index 89% rename from packages/client/runtime/src/client/sessions/projection-store.ts rename to packages/api/session-controller/src/client/sessions/projection-store.ts index ba3588c46d..4ffa2368bb 100644 --- a/packages/client/runtime/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -2,14 +2,14 @@ * Generic per-session projection value store (push model; see the * session-projection subsystem page, docs/subsystems/session-projection.md): * the host is the only computation site; the client holds finished - * whole values per key — `key → { value, seq }` — seeded by the history tail - * page's projections block and updated by `session/projection` push frames, + * whole values per key — `key → { value, seq }` — seeded by a Session page's + * projections block and updated by Session Controller `projection` frames, * under the single rule **higher seq wins**. No client-side domain folding * exists: a domain ships projection support with zero client code. Per-key * bare observable faces feed `useProjection` (ui-renderer binds them). */ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { ObservableSnapshot } from '../contract/store.ts' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { Notifier } from './notifier.ts' // The single projection type table, typed end to end (host unit, wire block, @@ -40,8 +40,8 @@ export type UseProjection = { } /** - * Tail-page projections baseline — structurally identical to the wire's - * `SessionProjectionsBlock` (apiproxy api layer), restated here so the + * Tail-page projections baseline — structurally identical to Session + * Controller's `SessionProjectionsBlock`, restated here so the * React-free store depends only on the type table, not the wire package's * response vocabulary. */ @@ -49,7 +49,7 @@ export interface ProjectionsBaseline { /** The consistent-cut seq (equals the window tail seq by construction). */ asOfSeq: number /** Whole current values by key; a registered key absent here means the capability is absent. */ - values: Partial + values: Readonly> } /** One key's row: the latest finished value and the seq it is consistent with. */ @@ -126,7 +126,7 @@ export class ProjectionValueStore { } /** - * Apply one finished value (the `session/projection` push-frame path). + * Apply one finished value from the Session control stream. * @param key - projection key. * @param value - whole value computed by the host unit. * @param seq - the unit's watermark at emission. @@ -160,13 +160,11 @@ export class ProjectionValueStore { } /** - * Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`): - * a row claiming knowledge beyond the host's own durable baseline rode - * state a restart lost — under last-wins it would wrongly outrank the - * host's recomputed (lower-seq) values forever. Durable replay and the next - * baseline re-seed whatever truly survived (the title-snapshot precedent, - * generalized). - * @param lastSeq - the subscribed frame's durable baseline seq. + * Drop rows beyond a replacement control baseline. Such rows describe + * process state the Host lost before persisting it and would otherwise + * outrank recomputed lower-seq values forever. The caller seeds the new + * baseline immediately afterward. + * @param lastSeq - highest durable sequence reflected by the baseline. */ truncate(lastSeq: number): void { for (const [key, row] of this.rows) { diff --git a/packages/client/runtime/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts similarity index 71% rename from packages/client/runtime/src/client/sessions/queue-mirror.ts rename to packages/api/session-controller/src/client/sessions/queue-mirror.ts index 1eb4e6fdbe..209349af2c 100644 --- a/packages/client/runtime/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -1,7 +1,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { MuxFrame } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionQueuedItem } from '../../types.ts' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { QueuedMessage } from './conversation.ts' +import type { QueuedMessage } from '../contract/snapshot.ts' const QUEUE_PREVIEW_CHARS = 200 @@ -18,7 +18,7 @@ function textOf(content: readonly ContentBlock[]): string | null { return content.map(block => block.text).join('') } -type QueueItems = Extract['items'] +type QueueItems = readonly SessionQueuedItem[] /** Authoritative transient queue projection and durable steering handoff. */ export class SessionQueueMirror { @@ -32,29 +32,22 @@ export class SessionQueueMirror { return this.current } - /** - * Drop the stale generation before its replacement queue baseline arrives. - * @returns whether any projected queue row was removed. - */ - reset(): boolean { - if (this.current.length === 0) return false - this.current = [] - return true - } - /** * Replace from one authoritative stream queue frame. * @param items - complete host queue snapshot. */ replace(items: QueueItems): void { - this.current = items.map(item => ({ - id: item.id, - messageId: item.message.id, - placement: item.placement, - content: item.message.content, - preview: previewOf(item.message.content), - text: textOf(item.message.content), - })) + this.current = items.map((item) => { + const content = item.message.content as unknown as readonly ContentBlock[] + return { + id: item.id, + messageId: item.message.id, + placement: item.placement, + content, + preview: previewOf(content), + text: textOf(content), + } + }) } /** diff --git a/packages/api/session-controller/src/client/sessions/remotes.ts b/packages/api/session-controller/src/client/sessions/remotes.ts new file mode 100644 index 0000000000..1449cc2c83 --- /dev/null +++ b/packages/api/session-controller/src/client/sessions/remotes.ts @@ -0,0 +1,29 @@ +/** + * Remote namespaces the Session cluster calls. One parameter for one concept: + * the generated surface a Session and its manager reach the Host through. + * + * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes + */ + +import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' +import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionRemote } from '../transport.ts' + +/** Narrow Commands namespace consumed by a Client Session. */ +export interface SessionCommandsRemote { + execute( + agentId: SessionId, + line: string, + images: readonly EncodedImageAttachment[], + signal?: AbortSignal, + ): Promise> +} + +/** Generated Remote namespaces consumed by the Client Session object layer. */ +export interface SessionRemotes { + readonly $stream: ClientRemote['$stream'] + readonly commands: SessionCommandsRemote + readonly session: SessionRemote +} diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts similarity index 74% rename from packages/client/runtime/src/client/sessions/service.ts rename to packages/api/session-controller/src/client/sessions/service.ts index f782d8cc71..d2594f600f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -1,5 +1,5 @@ /** - * SessionRuntime: root sessions service — list snapshot store (manager + * ClientSessions: root sessions service — list snapshot store (manager * projection; carries `current`, the persisted selection every * session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin * Fiber + ctx.extend scope tag; one scope per session, agent id === session @@ -16,26 +16,24 @@ */ import type { Context, Fiber } from '@deepseek-ai/cordis' import type { - IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, JobView, WorkspaceId, -} from '@deepseek-ai/dsh-api-remotes/client' -// Value import from the inline-safe wire layer (not the connection plugin): -// plugin-to-plugin value imports are a bundle purity error. -import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { - HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, -} from '@deepseek-ai/dsh-client-ui-slots' + IApiClient, SubagentAddress, +} from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' +import type { SessionJob as JobView } from '../../types.ts' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { SnapshotStore } from '../contract/store.ts' -import { createSnapshotStore } from '../contract/store.ts' +import { + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' +import type { ClientFailure, ClientResult } from '../contract/result.ts' +import type { SessionEventSource } from '../contract/events.ts' import type { SessionFace } from '../contract/session.ts' import type { AgentContext, ISessions } from '../contract/sessions.ts' -import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' -import type { ConversationRuntime } from './conversation-assembler.ts' +import { createScope, scopeOf as scopeTagOf } from '../scope.ts' import { SessionManager } from './manager.ts' import type { SessionRemotes } from './remotes.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' -import type { PendingInteractionStatus } from './pending.ts' -import { SessionProvideChannel } from './provide.ts' import type { Session } from './session.ts' /** Session list row projected from the host list RPC plus live stream increments. */ @@ -56,8 +54,6 @@ export interface SessionSummary { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' running: boolean - /** User interaction currently blocking this session (sidebar amber-dot state). */ - pendingInteraction?: PendingInteractionStatus /** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */ completed?: boolean /** @@ -75,7 +71,7 @@ export interface SessionSummary { /** * Session list store shape. `current` rides the same snapshot (arbitrated: * the single useSessions standard hook reads list and selection together — - * sidebar highlighting and SessionProvider share one fact source). + * sidebar highlighting and current-session consumers share one fact source). */ export interface SessionListState { /** Host-list order; addressed breadcrumb-only rows are excluded. */ @@ -88,9 +84,9 @@ export interface SessionListState { /** Direct durable catalogs keyed by their selected parent address. */ subagentsByParent: Readonly> /** - * Background jobs each session can see, mirrored last-wins from - * `session/jobs`. A missing key is an empty set — the Host sends no baseline - * for a session without tasks — so consumers read absence, never a sentinel. + * Background jobs each session can see, mirrored last-wins from Session + * Controller's control baseline and `jobs` frames. A missing key is an empty + * set, so consumers read absence rather than a sentinel. */ jobsBySession: Readonly> /** Current session's catalog-derived address, absent on ordinary navigation. */ @@ -112,7 +108,7 @@ export class SessionCreateError extends Error { * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. */ constructor( - readonly rpcError: RpcError, + readonly rpcError: ClientFailure, readonly requestedSessionId: SessionId | undefined, ) { super(`session create failed: ${rpcError.code}: ${rpcError.message}`) @@ -128,25 +124,27 @@ export class SessionForkError extends Error { * @param sourceSessionId - the session the fork was cut from. */ constructor( - readonly rpcError: RpcError, + readonly rpcError: ClientFailure, readonly sourceSessionId: SessionId, ) { super(`session fork failed: ${rpcError.code}: ${rpcError.message}`) } } -/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ +/** Identity-stable logical binding for one materialized Client Session. */ export interface SessionBinding { readonly sessionId: SessionId /** The outward session face only — feature code never sees the concrete class. */ readonly session: SessionFace + /** Contiguous event window reserved for Conversation assembly. */ + readonly eventSource: SessionEventSource readonly ctx: AgentContext } -// Scope primitives live in ../agents/scope.ts (the client mirror of host +// Scope primitives live in ../scope.ts (the client mirror of host // dsh-scope, keyed by Agent identity); re-exported here so existing // consumers keep their import site. -export { scopeOf } from '../agents/scope.ts' +export { scopeOf } from '../scope.ts' /** * Workspace display title of a session cwd: the path's last non-empty @@ -199,34 +197,10 @@ interface ScopeRecord { binding: SessionBinding /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */ session: Session - /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */ - provideInfo: SessionProvideInfo -} - -/** One plugin's per-session standard-props contribution (see {@link SessionRuntime.provide}). */ -export interface SessionProvideContribution { - /** Bare observable sources, keyed by hook base name ('input' → useInput). */ - hooks?: Record> - /** Stable plain members (action callbacks etc.), spread into standard props verbatim. */ - props?: Record -} - -/** - * Static declaration plus per-session resolver for one standard-kit - * contribution. The declared names let the renderer construct the same hook - * and prop surface while no session is current. - */ -export interface SessionProvideDescriptor { - /** Hook base names (`input` becomes `useInput`). */ - hooks?: readonly string[] - /** Plain standard-prop names. */ - props?: readonly string[] - /** Resolve every declared member for one definite session. */ - resolve(binding: SessionBinding): SessionProvideContribution } /** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */ -export class SessionRuntime implements ISessions { +export class ClientSessions implements ISessions { /** * The wire schema's own result bound, re-exposed for presentation plugins as * injected data. Not per-connection state: the `session.search` response @@ -238,18 +212,10 @@ export class SessionRuntime implements ISessions { readonly list: SnapshotStore /** The object-layer instance cluster and frame dispatch entry. */ private readonly manager: SessionManager - /** - * Atomic current-session provide projection: selection changes and - * provider-roster changes publish through this one source (the renderer - * host's `sessions.provide` feed), so a roster change under a stable - * current id republishes the bundle instead of stranding mounted entries. - */ - readonly currentProvideInfo: HostObservable - /** * Persisted selection cell (the durable half of `list.current`). Private on * purpose: reads go through the list snapshot; writes through {@link - * SessionRuntime.open} / {@link SessionRuntime.clear}. Projection + * ClientSessions.open} / {@link ClientSessions.clear}. Projection * validates it against the live list instead of destructively pruning, so a * selection survives transient list states (reconnect re-pull) and * resurfaces when its session returns. @@ -257,8 +223,8 @@ export class SessionRuntime implements ISessions { private readonly selection: SnapshotStore private readonly scopes = new Map() - /** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */ - private readonly provideChannel: SessionProvideChannel + /** In-flight scope drops remain here after records leave `scopes`, so root disposal can await quiescence. */ + private readonly scopeDrops = new Set>() /** * The staged session id — follows `list.current` exactly, holding its last * defined value across masked gaps (a transiently absent selection blanks @@ -273,31 +239,21 @@ export class SessionRuntime implements ISessions { * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. * @param remote - generated Remote namespaces shared with every Session. - * @param conversationRuntime - same-pass registry instances, when runtime apply owns them. */ constructor( private readonly rootCtx: Context, api: IApiClient, remote: SessionRemotes, - conversationRuntime?: ConversationRuntime, ) { this.selection = createSnapshotStore( {}, { persist: { name: 'dsh.sessions.current' } }) const restored = this.selection.getSnapshot() - const conversationEvents = rootCtx.get('conversationEvents') - const conversationViews = rootCtx.get('conversationViews') - const conversation = conversationRuntime ?? ( - conversationEvents === undefined || conversationViews === undefined - ? undefined - : { events: conversationEvents, views: conversationViews } - ) this.manager = new SessionManager( api, remote, restored.sessionId, restored.subagentAddress, - conversation, ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', @@ -305,66 +261,32 @@ export class SessionRuntime implements ISessions { }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. - this.manager.subscribe(() => { this.projectList() }) + const disposeManagerProjection = this.manager.subscribe(() => { + this.projectList() + }) // Stage follower: every current write (open() and projection alike) // re-evaluates staging, so startup restore (persisted selection validated // by the projection) and reconnect resurfacing open their window with no // dedicated code path. Safe to run synchronously inside the store notify: // the follower writes no list state — session.open()'s synchronous prefix // touches only session-side state and its own microtask-batched notifier. - // The current-provide projection follows the same current writes. - this.list.subscribe(() => { + const disposeStageFollower = this.list.subscribe(() => { this.followCurrent() - this.provideChannel.publishCurrent() }) - this.provideChannel = new SessionProvideChannel({ - rebuildBundles: () => { - for (const record of this.scopes.values()) { - record.provideInfo = this.provideChannel.materializeInfo(record.binding) - } - }, - resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current), - }) - this.currentProvideInfo = this.provideChannel.currentProvideInfo - let registryRebuildQueued = false - const scheduleRegistryRebuild = (): void => { - if (registryRebuildQueued) return - registryRebuildQueued = true - queueMicrotask(() => { - registryRebuildQueued = false - this.manager.rebuildConversationRegistry() - }) - } - if (conversation !== undefined) { - rootCtx.effect(() => { - const disposeEvents = conversation.events.subscribe(scheduleRegistryRebuild) - const disposeViews = conversation.views.subscribe(scheduleRegistryRebuild) - return () => { - disposeEvents() - disposeViews() - } - }, 'sessions: conversation registry rebuild') - } + rootCtx.effect(() => async () => { + disposeStageFollower() + disposeManagerProjection() + const scopes = [...this.scopes] + this.scopes.clear() + this.deferredRemovals.clear() + this.watched = undefined + for (const [id, record] of scopes) this.startScopeDrop(id, record) + await this.drainScopeDrops() + await this.manager.dispose() + }, 'session-controller.client.sessions') rootCtx.reflect.provide('sessions', this, undefined) } - /** - * Register a per-session standard-props provider: every session-scope slot - * component receives the contributed members as standard props (`hooks` - * sources become `use` selector hooks on the render side; `props` - * spread verbatim). Contributions materialize lazily with the session's - * scope record and die with it. Registration order is resolution order; - * duplicate member names fail loud at materialization. - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops). - */ - provide(descriptor: SessionProvideDescriptor): () => void { - // Scopes may already exist (boot order: the list lands and resolves - // scopes before later plugins register) — the channel rebuilds their - // bundles through the host hooks so every provider lands by first render. - return this.provideChannel.provide(descriptor) - } - /** * Select a listed or retained catalog-addressed session as current. * @param id - listed or addressed session id. @@ -392,7 +314,7 @@ export class SessionRuntime implements ISessions { } /** - * Inform the runtime whether a catalog menu is consuming membership updates. + * Inform the Session Controller whether a catalog menu is consuming membership updates. * @param parentSessionId - selected parent. * @param open - menu state. */ @@ -441,24 +363,56 @@ export class SessionRuntime implements ISessions { search( query: string, signal: AbortSignal, - ): Promise> { + ): Promise> { return this.manager.search(query, signal) } /** - * Route a mux stream envelope into the Session object layer. - * @param envelope - validated mux stream envelope. + * Apply one Session Controller live-control frame. + * @param frame - baseline or live control replacement. */ - handleMuxEnvelope(envelope: Parameters[0]): void { - this.manager.handleMuxEnvelope(envelope) + handleControlFrame(frame: Parameters[0]): void { + this.manager.handleControlFrame(frame) } /** - * Route a Host stream envelope into the Session object layer. - * @param envelope - validated Host stream envelope. + * Apply one remotely forwarded Session-list addition. + * @param summary - current Host summary for the added Session. */ - handleHostEnvelope(envelope: Parameters[0]): void { - this.manager.handleHostEnvelope(envelope) + handleSessionAdded(summary: Parameters[0]): void { + this.manager.handleSessionAdded(summary) + } + + /** + * Apply one remotely forwarded Session removal. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: Parameters[0]): void { + this.manager.handleSessionRemoved(sessionId) + } + + /** + * Apply one remotely forwarded running-state change. + * @param args - Session identity and current Agent running state. + */ + handleSessionStatus(...args: Parameters): void { + this.manager.handleSessionStatus(...args) + } + + /** + * Apply one remotely forwarded list-activity change. + * @param args - Session identity and durable activity timestamp. + */ + handleSessionActivity(...args: Parameters): void { + this.manager.handleSessionActivity(...args) + } + + /** + * Apply one remotely forwarded Agent failure. + * @param args - Session identity and caller-visible failure description. + */ + handleSessionError(...args: Parameters): void { + this.manager.handleSessionError(...args) } /** Rebuild the Session baseline and every opened window after connection. */ @@ -466,15 +420,10 @@ export class SessionRuntime implements ISessions { this.manager.handleConnected() } - /** Drop generation-scoped live interaction state the moment a connection generation dies. */ - handleDisconnected(): void { - this.manager.handleDisconnected() - } - /** * Create a session on the host. Resolution guarantee: by the time the * promise resolves, the created session is in the list store and - * {@link SessionRuntime.binding} resolves it — callers (New Session + * {@link ClientSessions.binding} resolves it — callers (New Session * draft hand-off) may address the scope synchronously, without waiting a * notifier flush. The synchronous projection below makes this structural * rather than an accident of microtask ordering. @@ -491,7 +440,7 @@ export class SessionRuntime implements ISessions { /** * Fork a session from a completed-turn prefix of the source (same - * synchronous-addressability guarantee as {@link SessionRuntime.create}: + * synchronous-addressability guarantee as {@link ClientSessions.create}: * on resolution the child is in the list store and open() can target it). * @param opts - source session id, the optional event seq anchoring the * cut (the boundary is the first turn/end at or after it; an in-log @@ -540,6 +489,18 @@ export class SessionRuntime implements ISessions { return this.resolve(id)?.ctx } + /** + * Materialize the Agent scope named by a validated Host Remote Event. + * The first successful Session-list baseline becomes authoritative for its + * lifetime; until then, transport streams may address the scope in either + * arrival order. + * @param id - Host-projected Agent identity (the matching Session id). + * @returns the identity-stable Agent Context. + */ + resolveAgentScope(id: SessionId): AgentContext { + return (this.scopes.get(id) ?? this.materializeScope(id)).ctx + } + /** * Read the Agent scope tag off a context. Service-method boundary: fetch * bundles must reach scope resolution through ctx.sessions — a cross-bundle @@ -557,7 +518,7 @@ export class SessionRuntime implements ISessions { * hop every scoped consumer (event listeners, per-session controllers) * takes from ctx-space into object-space (the client mirror of host * `agent.session`). Same service-method boundary as - * {@link SessionRuntime.scopeOf}. + * {@link ClientSessions.scopeOf}. * @param ctx - an Agent-scoped context. * @returns the session face, or undefined when the ctx is untagged or its scope was pruned. */ @@ -577,25 +538,6 @@ export class SessionRuntime implements ISessions { return this.resolve(id)?.binding } - /** - * Resolve one session's render-layer standard-props bundle (ctx never - * enters the render layer; the renderer subscribes to - * {@link SessionRuntime.currentProvideInfo}). Pure resolution — render-safe: - * no staging, no window side effects (StrictMode double-invokes and - * concurrent discarded passes must stay free). - */ - private provideInfo(id: string): SessionProvideInfo | undefined { - return this.resolve(id as SessionId)?.provideInfo - } - - /** - * Resolve the current-session-optional standard kit. Unknown or absent ids - * return the static no-session projection rather than removing hook props. - */ - private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { - return (id === undefined ? undefined : this.provideInfo(id)) ?? this.provideChannel.maybeInfo - } - /** * Move the stage to the list's current session: sweep teardowns deferred * behind the previous occupant and pull the new occupant's history window. @@ -632,19 +574,22 @@ export class SessionRuntime implements ISessions { const existing = this.scopes.get(id) if (existing !== undefined) return existing if (!this.eligible(id)) return undefined + return this.materializeScope(id) + } + + /** Materialize one scope after its caller establishes that the id may be addressed. */ + private materializeScope(id: SessionId): ScopeRecord { const { fiber, ctx } = createScope(this.rootCtx, id) const session = this.manager.get(id) // The Session owns its scoped dispatch point (host Agent.loopCtx mirror); // mint and bind are one step so a live scope record implies a bound actx. session.bindScope(ctx) - const binding: SessionBinding = { sessionId: id, session, ctx } + const binding: SessionBinding = { sessionId: id, session, eventSource: session.eventSource, ctx } const record: ScopeRecord = { fiber, ctx, binding, session, - // Sources are bare observables; React binds selector hooks at its own boundary. - provideInfo: this.provideChannel.materializeInfo(binding), } this.scopes.set(id, record) return record @@ -672,9 +617,6 @@ export class SessionRuntime implements ISessions { ...(entry.completed ? { completed: true } : {}), blank: entry.blank, updatedAt: entry.updatedAt, - ...(entry.pendingInteraction === undefined - ? {} - : { pendingInteraction: entry.pendingInteraction }), ...(entry.projectionValues === undefined ? {} : { projectionValues: entry.projectionValues }), @@ -735,6 +677,7 @@ export class SessionRuntime implements ISessions { /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */ private pruneScopes(): void { + if (this.list.getSnapshot().phase === 'pending') return for (const [id, record] of this.scopes) { if (this.eligible(id)) continue if (id === this.watched) { @@ -743,7 +686,22 @@ export class SessionRuntime implements ISessions { } this.scopes.delete(id) this.deferredRemovals.delete(id) - this.dropScope(id, record) + this.startScopeDrop(id, record) + } + } + + private startScopeDrop(id: SessionId, record: ScopeRecord): void { + const drop = this.dropScope(id, record) + this.scopeDrops.add(drop) + void drop.then( + () => { this.scopeDrops.delete(drop) }, + () => { this.scopeDrops.delete(drop) }, + ) + } + + private async drainScopeDrops(): Promise { + while (this.scopeDrops.size > 0) { + await Promise.allSettled([...this.scopeDrops]) } } @@ -751,18 +709,17 @@ export class SessionRuntime implements ISessions { * One teardown for the whole per-session axis: the scope * fiber (cascading every actx-registered effect: input shell, slash * controller, popup, plugin stores, listeners), the session-keyed slot - * stores, and the Session instance itself — the host session log is the + * registrations and the Session instance itself — the host session log is the * durable truth, a reopen lazily rebuilds and backfills via open(). */ - private dropScope(id: SessionId, record: ScopeRecord): void { - void record.fiber.dispose() + private async dropScope(id: SessionId, record: ScopeRecord): Promise { // Release the Session's dispatch point with the scope it belongs to (a // surviving instance — the live Intent — rebinds when resolve re-mints). record.session.unbindScope() - // Optional lookup: slots and sessions are sibling services with no - // declared dependency; a slots-less boot (object-layer tests) skips. - this.rootCtx.get('slots')?.pruneStoreScope(id) - this.manager.drop(id) + await Promise.allSettled([ + record.fiber.dispose(), + this.manager.drop(id), + ]) } /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */ @@ -784,7 +741,7 @@ export class SessionRuntime implements ISessions { * future teardown path cannot double-dispose. */ if (record !== undefined) { this.scopes.delete(id) - this.dropScope(id, record) + this.startScopeDrop(id, record) } } } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts new file mode 100644 index 0000000000..e894517e59 --- /dev/null +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -0,0 +1,667 @@ +// Sessions remain resident after creation so their open Remote sources keep running off-screen. + +import type { Context } from '@deepseek-ai/cordis' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { + IApiClient, SubagentAddress, +} from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { + SessionEventStream, + sessionStreamFailure, +} from '../transport.ts' +import type { SessionJournalChange } from '../transport.ts' +import type { + PromptContentPart, + QueueAction, + SessionAddress, + SessionControlFrame, + SessionEventEntry, + SessionQueuedItem, + SessionRequestId, + SessionError, + SessionToolView, +} from '../../types.ts' +import type { ClientFailure, ClientResult } from '../contract/result.ts' +import { transportResult } from '../contract/result.ts' +import type { SessionFace } from '../contract/session.ts' +import type { + OpenState, PromptError, SessionSnapshot, +} from '../contract/snapshot.ts' +import { MutableSessionEventSource } from '../contract/events.ts' +import { Notifier } from './notifier.ts' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionRemotes } from './remotes.ts' +import { ProjectionValueStore } from './projection-store.ts' +import type { ProjectionsBaseline } from './projection-store.ts' +import { resolvedClientTimeZone } from '../time-zone.ts' +import { SessionQueueMirror } from './queue-mirror.ts' + +/** Messages requested per history page. */ +export const PAGE_MESSAGES = 50 + +/** Manager-owned observers of a Session object's local state edges. */ +export interface SessionOptions { + /** Catalog-discovered address selecting non-activating subagent transport. */ + address?: SubagentAddress + /** Whether the exact direct parent Agent was live at the latest catalog read. */ + parentAvailable?: boolean + /** + * First ACCEPTED prompt on a blank session (fires at most once, on the + * prompt RPC's success response): the manager mirrors the blank→false flip + * into its list row so the session surfaces without waiting for a host + * frame. Acceptance is the flip point because it proves the user message + * is in the host log; a rejected first prompt keeps the session blank + * (hidden, still reusable by connectWorkspace). + */ + onEngaged?(session: Session): void + /** + * Manager-owned projection value store to adopt (frames route through the + * manager and values outlive instantiation); omitted, the Session owns a + * private store (bare object-layer construction). + */ + projections?: ProjectionValueStore +} + +/** + * Owns a session's event window, lifecycle state, and observable + * snapshot. React bindings remain outside this data layer. Features see only + * the {@link SessionFace} slice (ISession verbs + the snapshot source); the + * remaining public members are Session Controller internals. + */ +export class Session implements SessionFace { + // ---- Window and derived state (all private; the snapshot is the only read API) ---- + private eventWindow: SessionEvent[] = [] + /** Wire views aligned with `eventWindow` by index (envelope annotations; undefined = no view). + * Kept parallel so `eventWindow` remains the raw log slice (model-visible ⟺ logged). */ + private views: (SessionToolView | undefined)[] = [] + private baseSeq = 0 + private hasMore = false + private openState: OpenState = 'cold' + private openError: ClientFailure | null = null + private openPromise: Promise | null = null + /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale + * passes drop all writes once the generation moves on. */ + private openGeneration = 0 + private loadingOlder = false + /** Authoritative stream-only inbox snapshot; pending work never hits history. */ + private readonly queueMirror = new SessionQueueMirror() + private running = false + private address: SubagentAddress | undefined + private parentAvailable = false + /** + * Sticky send marker, private input of the composerPhase derivation: set + * synchronously before prompt()'s first await, never reset — the blank → + * engaging edge of the phase machine (see ComposerPhase). + */ + private promptAttempted = false + /** A first accepted prompt stays in the engaging phase until its turn is observable. */ + private firstPromptPendingTurn = false + /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */ + private blankBit = true + private removed = false + private promptError: PromptError | null = null + private lastAgentError: string | null = null + /** Owns the addressed page/follow lifecycle while this Session is open. */ + private events: SessionEventStream | undefined + + /** + * Per-session projection value store (push model; see the session-projection + * subsystem page, docs/subsystems/session-projection.md): finished whole + * values computed on the Host, seeded by the tail page's + * projections block and updated by Session Controller control frames under the + * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)` + * (the useProjection resolution face); the conversation snapshot never + * carries projection values, and no client-side domain folding exists. + * Manager-owned when constructed through SessionManager (frames route and + * the store outlives instantiation, the title-snapshot precedent); a bare + * construction gets a private store. + */ + readonly projections: ProjectionValueStore + + /** Contiguous history and live tail consumed by Conversation assembly. */ + readonly eventSource = new MutableSessionEventSource() + private snapshotCache: SessionSnapshot + private readonly notifier: Notifier + /** + * Agent-scoped cordis context, bound once by ClientSessions when it + * mints the scope (the client mirror of the host Agent's loopCtx). The + * Session dispatches its own scoped events through it; undefined means + * unbound (bare object-layer construction) or already pruned — both skip + * dispatch-dependent behavior rather than fail. + */ + private actx: Context | undefined + + /** + * @param sessionId - Host session identity (client sessions are always Host-born). + * @param api - shared wire client. + * @param remote - generated Remote namespaces this session calls. + * @param options - optional manager-owned state observers. + */ + constructor( + readonly sessionId: SessionId, + private readonly api: IApiClient, + private readonly remote: SessionRemotes, + private readonly options: SessionOptions = {}, + ) { + this.projections = options.projections ?? new ProjectionValueStore() + this.address = options.address + this.parentAvailable = options.parentAvailable ?? false + this.notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot() + }) + this.snapshotCache = this.buildSnapshot() + } + + /** + * Bind the Agent-scoped context minted by ClientSessions (single write; + * a second bind is a wiring error and throws). Direction stays one-way at + * this binding boundary: consumers still reach the Session via `sessions.sessionOf`, + * while the Session holds its own dispatch point (host Agent.loopCtx + * mirror). + * @param actx - the agent's scoped context. + */ + bindScope(actx: Context): void { + if (this.actx !== undefined) throw new Error(`session ${this.sessionId} already has a bound scope`) + this.actx = actx + } + + /** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */ + unbindScope(): void { + this.actx = undefined + } + + // ---- Operations ---- + + /** + * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. + * @param content - text plus browser-owned temporary image uploads. + * @param mode - queue appends after the current turn; steer interrupts it. + * @returns the prompt result (also mirrored into promptError on failure). + */ + async prompt( + content: PromptContentPart[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> { + this.promptError = null + this.lastAgentError = null + // Synchronous, before the first await: the blank → engaging edge must be + // visible on the session area's very first frame when a caller sends + // ahead of navigation (first-send flow). + this.promptAttempted = true + if (this.blankBit) this.firstPromptPendingTurn = true + this.notifier.markDirty() + let result: ClientResult<{ accepted: true }> + try { + if (this.address === undefined) { + const clientTimeZone = resolvedClientTimeZone() + result = toSessionResult(await this.remote.session.prompt({ + requestId: randomUUID() as SessionRequestId, + sessionId: this.sessionId, + mode, + content, + clientTimeZone, + }, signal)) + } else if (this.address.mode === 'one-shot') { + result = { + ok: false, + error: { + code: 'subagent-not-resumable', + message: 'one-shot subagent conversations are read-only', + details: { childSessionId: this.address.childSessionId }, + }, + } + } else { + if (content.some(part => part.type === 'image')) { + result = { + ok: false, + error: { + code: 'attachment-error', + message: 'Image input is unavailable for subagent continuations.', + details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, + }, + } + } else { + const routed = (await this.api.subagents.prompt({ + ...this.address, + content: content.flatMap(part => part.type === 'text' + ? [{ type: 'text' as const, text: part.text }] + : []), + clientTimeZone: resolvedClientTimeZone(), + }, signal)).result + result = routed.ok ? { ok: true, value: { accepted: true } } : routed + } + } + } catch (error) { + result = transportResult(error) + } + if (!result.ok) { + this.promptError = { op: 'send', error: result.error } + this.notifier.markDirty() + return result + } + // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the + // conversation's first turn on the host (the host criterion — a logged + // turn/start — is fact, not optimism; standalone command and projection + // events never flip it), while a rejected first prompt must keep the + // session blank — the client-side blank mirror only ever lowers, so + // flipping early on a failure would surface the session forever and + // strip its connectWorkspace reuse eligibility against the host's + // authority. + if (this.blankBit) { + this.blankBit = false + this.options.onEngaged?.(this) + this.notifier.markDirty() + } + return result + } + + /** + * Resolve one image referenced by this session into browser-consumable bytes. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + async readAttachment( + attachmentId: AttachmentIdType, + ): Promise> { + try { + const result = await this.remote.session.attachment({ + sessionId: this.sessionId, + attachmentId, + }) + if (!result.ok) return toSessionResult(result) + const binary = atob(result.value.data) + const data = Uint8Array.from(binary, char => char.charCodeAt(0)) + return { ok: true, value: { attachment: result.value.attachment, data } } + } catch (error) { + return transportResult(error) + } + } + + /** Apply one operation to a still-pending queue occurrence. */ + async updateQueue(itemId: MessageId, action: QueueAction): Promise> { + try { + return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action })) + } catch (error) { + return transportResult(error) + } + } + + /** + * Stop the active turn while the Host preserves pending inbox work; failures + * land in promptError (same error-strip display slot). A continuable + * subagent address routes through `subagent.interrupt`, whose durable + * parent-address authority works without a live parent Agent; a one-shot + * address stays uncancellable (the UI offers no stop action, so this arm is + * defensive). + * @returns the cancel result. + */ + async cancel(): Promise> { + const address = this.address + if (address !== undefined && address.mode === 'one-shot') { + const result: ClientResult<{ accepted: true }> = { + ok: false, + error: { + code: 'subagent-delivery-unavailable', + message: 'subagent activation cancellation is unavailable', + details: { childSessionId: address.childSessionId }, + }, + } + this.promptError = { op: 'stop', error: result.error } + this.notifier.markDirty() + return result + } + let result: ClientResult<{ accepted: true }> + try { + result = address !== undefined + ? (await this.api.subagents.interrupt(address)).result + : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId })) + } catch (error) { + result = transportResult(error) + } + if (!result.ok) { + this.promptError = { op: 'stop', error: result.error } + this.notifier.markDirty() + } + return result + } + + /** + * Rename: contract session.rename 1:1. On success settle the 'title' + * projection cell from the response's `{title, seq}` under the store's + * higher-seq-wins rule (the push frame arriving later is a no-op replay), + * so the list row and any useProjection('title') reader update without + * waiting for the control-stream projection update. + * @param title - raw title text (the host normalizes acceptance). + * @returns the rename result (normalized accepted title + title event seq). + */ + async rename(title: string): Promise> { + try { + const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title })) + if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) + return result + } catch (error) { + return transportResult(error) + } + } + + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle; + * outcomes render as flow nodes, never as a response echo). + * @param line - the full command line, leading slash included. + * @returns the admission result, or the error branch on transport failure. + */ + async command(line: string): Promise> { + const result = await this.remote.commands.execute(this.sessionId, line, []) + if (!result.ok) return result + return { ok: true, value: { matched: result.value !== undefined } } + } + + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ + open(): Promise { + if (this.openState === 'open') return Promise.resolve() + if (this.openPromise !== null) return this.openPromise + const promise = this.doOpen(this.openGeneration).finally(() => { + // Identity-guarded: a superseded open must not null out the promise resync just started. + if (this.openPromise === promise) this.openPromise = null + }) + this.openPromise = promise + return promise + } + + /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */ + async loadOlder(): Promise { + if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return + const events = this.events + if (events === undefined) return + this.loadingOlder = true + this.notifier.markDirty() + try { + await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES }) + } catch (error) { + if (sessionStreamFailure(error) === undefined) { + console.error('[session-controller] loadOlder failed:', error) + } + } finally { + this.loadingOlder = false + this.notifier.markDirty() + } + } + + /** Rebuild an opened history source after address replacement. + * Invalidates any in-flight open first; queue state belongs to the independently + * reconnecting control stream and remains untouched. */ + async resync(): Promise { + if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) + this.openGeneration++ + const events = this.events + this.events = undefined + await events?.dispose() + this.openPromise = null + this.openState = 'cold' + this.openError = null + this.eventWindow = [] + this.views = [] + this.baseSeq = 0 + this.notifier.markDirty() + await this.open() + } + + // ---- Subscription API (useSyncExternalStore direct wiring) ---- + + /** + * uSES subscription entry. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener) + } + + /** + * Cached Session snapshot (rebuilt lazily when dirty with no listeners). + * @returns the cached reference (stable until the next flush). + */ + getSnapshot(): SessionSnapshot { + this.notifier.ensureFresh() + return this.snapshotCache + } + + // ---- Manager-only entry points (@internal; never called by the UI) ---- + + /** + * Replace every transient control value for this Session from one stream baseline. + * @param queue - complete pending queue for this Session. + */ + replaceControl(queue: readonly SessionQueuedItem[]): void { + this.queueMirror.replace(queue) + this.notifier.markDirty() + } + + /** + * Apply one Session-addressed live control update. + * @param frame - queue replacement addressed to this Session. + */ + handleControlFrame(frame: Extract): void { + this.queueMirror.replace(frame.items) + this.notifier.markDirty() + } + + /** + * Running-bit relay from the host stream (list entry and snapshot stay consistent). + * @param running - the new running state. + */ + handleRunning(running: boolean): void { + // Turn-start conversion: a blank session never runs, so the first + // running:true proves another side's first message landed. + if (running && this.blankBit) { + this.blankBit = false + this.notifier.markDirty() + } + if (running) this.firstPromptPendingTurn = false + if (this.running === running) return + this.running = running + this.notifier.markDirty() + } + + /** + * Install or clear the catalog-discovered transport address. A changed + * address rebuilds an already-open window through its new history route. + * @param address - direct parent/child address, or undefined for ordinary transport. + * @param parentAvailable - latest exact-parent availability hint. + */ + configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void { + const same = this.address?.parentSessionId === address?.parentSessionId + && this.address?.childSessionId === address?.childSessionId + && this.address?.mode === address?.mode + this.address = address + this.parentAvailable = parentAvailable + if (!same && this.openState !== 'cold') void this.resync() + else this.notifier.markDirty() + } + + /** + * Update only the parent availability hint from a catalog refresh. + * @param available - whether the exact direct parent is live. + */ + handleSubagentParentAvailable(available: boolean): void { + if (this.parentAvailable === available) return + this.parentAvailable = available + this.notifier.markDirty() + } + + /** + * Blank-bit relay from the authoritative summary source (`session.list` and + * `api-session/added`). Monotone: once any signal (local first send, + * running flip, an earlier summary) cleared it, a stale true never + * re-blanks. + * @param blank - the summary's derived empty-log bit. + */ + handleBlank(blank: boolean): void { + if (blank === this.blankBit) return + if (blank && (this.promptAttempted || this.running)) return + this.blankBit = blank + this.notifier.markDirty() + } + + /** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */ + handleRemoved(): void { + this.removed = true + this.notifier.markDirty() + } + + /** + * `api-session/error` relay: the outlet for live failures with no turn position. + * @param message - the stringified error. + */ + handleAgentError(message: string): void { + this.lastAgentError = message + this.notifier.markDirty() + } + + /** + * Stop the Session's live Remote source. + * @returns when the Remote iterator has completed teardown. + */ + async dispose(): Promise { + this.openGeneration++ + const events = this.events + this.events = undefined + await events?.dispose() + } + + // ---- Private ---- + + /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */ + private async doOpen(generation: number): Promise { + this.openState = 'loading' + this.openError = null + this.notifier.markDirty() + const events = new SessionEventStream(this.remote, this.sessionAddress(), { + publish: (change) => { + if (generation !== this.openGeneration || this.events !== events) return + this.acceptEventChange(change) + }, + failed: (error) => { + this.failEventStream(events, generation, error) + }, + }) + this.events = events + try { + await events.open({ maxMessages: PAGE_MESSAGES }) + if (generation !== this.openGeneration || this.events !== events) return + this.openState = 'open' + } catch (error) { + if (generation !== this.openGeneration || this.events !== events) return + this.events = undefined + this.openState = 'error' + this.openError = openFailure(error) + } finally { + if (generation === this.openGeneration) this.notifier.markDirty() + } + } + + /** Apply one contiguous journal update already reconciled by the Remote stream. */ + private acceptEventChange(change: SessionJournalChange): void { + switch (change.type) { + case 'replace': + this.installWindow(change.entries, change.hasMore, change.page.projections) + return + case 'prepend': + this.prependWindow(change.entries, change.hasMore) + return + case 'append': + if (this.appendLive(change.entry)) this.notifier.markDirty() + } + } + + /** Replace the complete contiguous window and apply page-owned projection metadata. */ + private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { + this.eventWindow = entries.map(entry => entry.event as SessionEvent) + this.views = entries.map(entry => entry.view) + this.baseSeq = this.eventWindow[0]?.seq ?? 0 + this.hasMore = hasMore + if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false + if (projections !== undefined) this.projections.seed(projections) + this.eventSource.replace(entries, hasMore) + this.notifier.markDirty() + } + + /** Prepend one stream-validated history page. */ + private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void { + this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow] + this.views = [...entries.map(entry => entry.view), ...this.views] + this.baseSeq = this.eventWindow[0]?.seq ?? 0 + this.hasMore = hasMore + this.eventSource.prepend(entries, hasMore) + } + + /** Append one stream-validated live event. */ + private appendLive(entry: SessionEventEntry): boolean { + const event = entry.event as SessionEvent + this.eventWindow.push(event) + this.views.push(entry.view) + const awaitingFirstTurn = this.firstPromptPendingTurn + if (event.type === 'turn/start') this.firstPromptPendingTurn = false + const queueChanged = this.queueMirror.acceptDurable(event) + this.eventSource.append(entry) + return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn + } + + /** Publish a terminal background failure only while this stream still owns the Session. */ + private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { + if (generation !== this.openGeneration || this.events !== events) return + this.openGeneration++ + this.events = undefined + this.openPromise = null + this.openState = 'error' + this.openError = openFailure(error) + void events.dispose() + this.notifier.markDirty() + } + + private buildSnapshot(): SessionSnapshot { + return { + sessionId: this.sessionId, + queue: this.queueMirror.snapshot(), + running: this.running, + subagent: this.address === undefined + ? null + : { address: this.address, parentAvailable: this.parentAvailable }, + removed: this.removed, + openState: this.openState, + openError: this.openError, + hasMore: this.hasMore, + loadingOlder: this.loadingOlder, + promptError: this.promptError, + blank: this.blankBit, + lastAgentError: this.lastAgentError, + promptAttempted: this.promptAttempted, + awaitingFirstTurn: this.firstPromptPendingTurn, + } + } + + private sessionAddress(): SessionAddress { + return this.address === undefined + ? { kind: 'session', sessionId: this.sessionId } + : { kind: 'subagent', ...this.address } + } +} + +/** Convert a terminal Session stream failure to the Client error vocabulary. */ +function openFailure(error: unknown): ClientFailure { + const failure = sessionStreamFailure(error) + if (failure !== undefined) return failure as SessionError + const folded = transportResult(error) + /* v8 ignore next -- transportResult never returns an ok result. */ + if (folded.ok) throw new Error('transportResult returned an unexpected success') + return folded.error +} +/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ +function toSessionResult(result: RemoteResult): ClientResult { + return result.ok ? result : { ok: false, error: result.error as SessionError } +} diff --git a/packages/client/runtime/src/client/sessions/subagent-lineage.ts b/packages/api/session-controller/src/client/sessions/subagent-lineage.ts similarity index 92% rename from packages/client/runtime/src/client/sessions/subagent-lineage.ts rename to packages/api/session-controller/src/client/sessions/subagent-lineage.ts index 518f45ab1e..14409cbe10 100644 --- a/packages/client/runtime/src/client/sessions/subagent-lineage.ts +++ b/packages/api/session-controller/src/client/sessions/subagent-lineage.ts @@ -2,9 +2,9 @@ * Pure subagent-lineage aggregation over the retained session-list mirror. * Ordinary forks terminate propagation so each visible session owns only its * uninterrupted subagent subtree. - * @module @deepseek-ai/dsh-client-runtime/client/sessions/subagent-lineage + * @module @deepseek-ai/dsh-api-session-controller/client/sessions/subagent-lineage */ -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionSummary } from './service.ts' /** Descendant counts projected for one possible parent session. */ diff --git a/packages/client/runtime/src/client/time-zone.ts b/packages/api/session-controller/src/client/time-zone.ts similarity index 100% rename from packages/client/runtime/src/client/time-zone.ts rename to packages/api/session-controller/src/client/time-zone.ts diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts new file mode 100644 index 0000000000..2b7452056c --- /dev/null +++ b/packages/api/session-controller/src/client/transport.ts @@ -0,0 +1,181 @@ +/** Session-specific adapters for Gateway-owned Remote stream lifecycles. */ + +import type {} from '@deepseek-ai/dsh-api-session-controller/remote' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { + RemoteJournalStream, + RemoteSnapshotStream, + RemoteStreamCarrierError, + RemoteStreamError, + type ClientRemote, + type RemoteJournalChange, + type RemoteJournalFrame, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { + SessionAddress, + SessionControlFrame, + SessionEventEntry, + SessionPage, + SessionPageRequest, +} from '../types.ts' + +export { + SESSION_SEARCH_RESULT_LIMIT, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, +} from '../types.ts' + +/** Pagination fields bound to an already-addressed Session journal. */ +export type ClientSessionPageRequest = Omit + +/** Complete generated `ctx.remote.session` namespace. */ +export type SessionRemote = ClientRemote['session'] + +/** One complete publication from the Session journal stream. */ +export type SessionJournalChange = RemoteJournalChange + +type SessionControlBaselineFrame = Extract +type SessionControlDeltaFrame = Exclude + +/** Gateway-owned control snapshot stream configured for Session frames. */ +export type SessionControlStream = RemoteSnapshotStream< + SessionControlBaselineFrame, + SessionControlDeltaFrame +> + +type SessionStreamRemote = Pick + +/** Domain sinks used by the Host-wide Session control stream. */ +export interface SessionControlStreamOptions { + /** Apply a complete baseline or one later update. */ + readonly accept: (frame: SessionControlFrame) => void + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void +} + +/** Domain sinks used by one addressed Session event journal. */ +export interface SessionEventStreamOptions { + /** Apply one complete event-window change. */ + readonly publish: (change: SessionJournalChange) => void + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void + /** Publish a terminal stream, page, or protocol failure after opening. */ + readonly failed: (error: unknown) => void +} + +/** + * Create the Host-wide Session control snapshot stream. + * @param remote - generated Session namespace and Gateway stream factory. + * @param options - Session state destinations. + * @returns an unstarted stream owned by the Client Session runtime. + */ +export function createSessionControlStream( + remote: SessionStreamRemote, + options: SessionControlStreamOptions, +): SessionControlStream { + const stream = remote.$stream({ + name: 'session control stream', + open: signal => remote.session.control(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('session control stream ended without a terminal result') + : new Error('session control stream ended before its opening snapshot'), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }) + return new RemoteSnapshotStream(stream, { + name: 'session control stream', + isSnapshot: (frame): frame is SessionControlBaselineFrame => frame.type === 'baseline', + replace: options.accept, + update: options.accept, + failed: options.failed, + }) +} + +/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */ +export class SessionEventStream extends RemoteJournalStream< + SessionPage, + SessionEventEntry, + number, + ClientSessionPageRequest +> { + /** + * @param remote - generated Session namespace and Gateway stream factory. + * @param address - durable ordinary-Session or direct-subagent address. + * @param options - Session event-window destinations. + */ + constructor( + private readonly remote: SessionStreamRemote, + private readonly address: SessionAddress, + options: SessionEventStreamOptions, + ) { + super(remote, { + name: 'session event stream', + emptyCursor: -1, + entries: page => page.events, + hasMore: page => page.hasMore, + cursor: entry => entry.event.seq, + compare: (left, right) => left - right, + follows: (left, right) => right === left + 1, + publish: options.publish, + ...(options.carrierFailed === undefined + ? {} + : { carrierFailed: options.carrierFailed }), + failed: options.failed, + }) + } + + /** @inheritdoc */ + protected override async * follow( + afterSeq: number | undefined, + signal: AbortSignal, + ): AsyncIterable> { + const request = afterSeq === undefined + ? { address: this.address } + : { address: this.address, afterSeq } + for await (const frame of this.remote.session.follow(request, signal)) { + if (frame.type === 'opened') { + yield frame + continue + } + const { type: _type, ...entry } = frame + yield { type: 'entry', entry } + } + } + + /** @inheritdoc */ + protected override async readPage( + request: ClientSessionPageRequest, + throughSeq: number, + signal: AbortSignal, + ): Promise { + const result = await this.remote.session.page( + { address: this.address, throughSeq, ...request }, + signal, + ) + if (!result.ok) { + throw new RemoteStreamError( + result.error.code, + result.error.message, + result.error.details, + ) + } + return result.value + } + + /** @inheritdoc */ + protected override repairRequest( + request: ClientSessionPageRequest, + ): ClientSessionPageRequest { + return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages } + } +} + +/** + * Recover a Host Session failure from a Remote stream terminal error. + * @param error - value thrown while opening or consuming a Session stream. + * @returns the Host failure, or `undefined` for carrier and local failures. + */ +export function sessionStreamFailure(error: unknown): RemoteFailure | undefined { + if (!(error instanceof RemoteStreamError)) return undefined + return { code: error.code, message: error.message, details: error.details } +} diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts new file mode 100644 index 0000000000..2f40aa1972 --- /dev/null +++ b/packages/api/session-controller/src/commands.ts @@ -0,0 +1,614 @@ +/** Session commands whose activation policy is explicit at each Remote method. */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' +import { + PresetMountError, UnknownPresetError, resolveSessionPreset, +} from '@deepseek-ai/dsh-agent-presets' +import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { + ReasoningEffortId, createUserMessage, freezeMessage, +} from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' +import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import type { Workspace } from '@deepseek-ai/dsh-workspace' +import { + ApiSessionAgentController, + ApiSessionCwdConflict, + ApiSessionNotFound, + ApiSessionPresetConflict, + ApiSessionSubagentOwnership, + apiSessionSubagentOwnershipError, + hasApiSessionSubagentOwner, + inspectApiSession, +} from './agent.ts' +import { buildModelCatalog } from './catalog.ts' +import type { + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionCreateRequest, + SessionCreateValue, + SessionForkRequest, + SessionForkValue, + SessionModels, + SessionModelsRequest, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, +} from './types.ts' + +interface SessionReadState { + readonly id: SessionId + readonly header: SessionHeader + readonly events: SessionEvent[] +} + +/** Implements Session business commands delegated by the Session Controller Remote service. */ +export class SessionCommandController { + /** + * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. + * @param agents - sole owner of create, resume, and Session-local model selection. + * @param defaultCwd - project directory used when create names neither a Workspace nor a cwd. + */ + constructor( + private readonly ctx: Context, + private readonly agents: ApiSessionAgentController, + private readonly defaultCwd: string, + ) {} + + /** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ + async create(request: SessionCreateRequest): Promise { + if (request.workspaceId !== undefined && request.cwd !== undefined) { + reject('bad-request', 'session.create accepts workspaceId or cwd, not both', {}) + } + const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`) + let workspace: Workspace | undefined + if (request.workspaceId !== undefined) { + workspace = this.ctx.workspaceRegistry.get(request.workspaceId) + if (workspace === undefined) { + reject('workspace-not-found', `workspace "${request.workspaceId}" not found`, { + workspaceId: request.workspaceId, + }) + } + } + const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd + let adopted: Agent + try { + adopted = await this.agents.ensureSession( + sessionId, + cwd, + request.sessionId !== undefined, + request.agentPreset, + ) + } catch (error) { + this.rejectCreation(sessionId, error) + } + if (workspace !== undefined) { + try { + await workspace.attachSession(sessionId) + } catch (error) { + reject( + 'workspace-attach-failed', + `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, + { sessionId, workspaceId: workspace.id }, + ) + } + } + const agentPreset = resolveSessionPreset(adopted.session) + return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) } + } + + /** + * Read the current selection and advisory model catalog, explicitly resuming the Session. + * @param request - Session whose model state is requested. + * @returns the current selection and available model groups. + */ + async models(request: SessionModelsRequest): Promise { + const agent = await this.resolveAgent(request.sessionId) + const current = this.agents.selectionFor(agent).current + const { groups, failures } = await buildModelCatalog(this.ctx) + return { + current: { ...current }, + routable: routeServed(this.ctx, current.provider), + groups, + failures, + } + } + + /** + * Validate and install one Session-local model selection. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session. + */ + async selectModel(request: SessionSelectModelRequest): Promise { + const agent = await this.resolveAgent(request.sessionId) + return this.agents.serializeImageAdmission(agent, async () => { + try { + const resolved = await this.ctx.llm.resolveCallConfig({ + provider: request.provider, + model: request.model, + ...(request.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(request.reasoningEffort) }), + }) + const selected: AgentModelSelection = { + provider: resolved.provider, + model: resolved.model, + ...(resolved.reasoningEffort === undefined + ? {} + : { reasoningEffort: resolved.reasoningEffort }), + } + this.agents.selectionFor(agent).current = selected + try { + await this.ctx.agentDefaultModel.saveSelection(selected) + } catch (error) { + this.ctx.logger.warn( + `session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`, + ) + } + return { selected: { ...selected } } + } catch (error) { + if (error instanceof TypertRemoteFailure) throw error + reject( + 'model-unavailable', + error instanceof Error ? error.message : String(error), + { provider: request.provider, model: request.model }, + ) + } + }) + } + + /** + * Normalize and append a user-owned Session title. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ + async rename(request: SessionRenameRequest): Promise { + const agent = await this.resolveAgent(request.sessionId) + const titles = this.ctx.get('sessionTitle') + if (titles === undefined) { + reject('internal', 'renaming is unavailable: this deployment mounts no session-title service', {}) + } + try { + const accepted = titles.rename(agent.session, request.title) + return { title: accepted.title, seq: accepted.eventSeq } + } catch (error) { + if (error instanceof SessionTitleInvalidError) { + reject('title-invalid', error.message, { sessionId: request.sessionId }) + } + reject( + 'internal', + `failed to rename session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + } + + /** + * Create a new ordinary Session from one completed-turn prefix. + * @param request - source Session and optional event anchor. + * @returns the new Session identity. + */ + async fork(request: SessionForkRequest): Promise { + if (request.atSeq !== undefined + && (!Number.isInteger(request.atSeq) || request.atSeq < 0)) { + reject('bad-request', 'atSeq must be a non-negative integer', {}) + } + let source: SessionReadState + try { + source = await this.readSessionState(request.sessionId) + } catch (error) { + if (error instanceof ApiSessionNotFound) { + reject('session-not-found', error.message, { sessionId: request.sessionId }) + } + reject( + 'internal', + `fork source unavailable for session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + const lastSeq = source.events.at(-1)?.seq ?? -1 + const atSeq = request.atSeq + const anchoredBoundary = atSeq === undefined + ? undefined + : source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq) + const boundary = anchoredBoundary + ?? (atSeq === undefined || atSeq > lastSeq + ? source.events.findLast(event => event.type === 'turn/end') + : undefined) + if (boundary === undefined) { + reject( + 'fork-unavailable', + atSeq !== undefined && atSeq <= lastSeq + ? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}` + : `session "${request.sessionId}" has no completed turn to fork from`, + { sessionId: request.sessionId }, + ) + } + let cut = boundary.seq + 1 + while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++ + let workspace: Workspace | undefined + try { + workspace = await this.forkWorkspace(source) + } catch (error) { + reject( + 'internal', + `failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + const childId = SessionId(`session-${randomUUID()}`) + const composition = await this.agents.composeAgent(resolveSessionPreset(source)) + try { + const { provider, model } = this.ctx.agentDefaultModel.currentSelection() + await this.ctx.agents.create({ + sessionId: childId, + seed: source.events.slice(0, cut), + meta: { + ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }), + parentSession: source.id, + seedLength: cut, + ...(composition.agentPreset === undefined + ? {} + : { agentPreset: composition.agentPreset }), + }, + agentOptions: { provider, model }, + setup: composition.setup, + }) + } catch (error) { + reject( + 'internal', + `failed to fork session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + if (workspace !== undefined) { + try { + await workspace.attachSession(childId) + } catch (error) { + reject( + 'workspace-attach-failed', + `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`, + { sessionId: childId, workspaceId: workspace.id }, + ) + } + } + return { sessionId: childId } + } + + /** + * Admit one browser prompt after explicit Agent resume and image validation. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @returns acknowledgement that the Agent accepted the prompt. + */ + async prompt(request: SessionPromptRequest): Promise { + const clientTimeZone = request.clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(request.clientTimeZone) + if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { + reject( + 'invalid-time-zone', + 'clientTimeZone must be UTC or a valid IANA Area/Location name', + { value: request.clientTimeZone }, + ) + } + const agent = await this.resolveAgent(request.sessionId) + const selection = this.agents.selectionFor(agent).current + if (!routeServed(this.ctx, selection.provider)) { + reject( + 'model-unavailable', + `no adapter serves provider "${selection.provider}"; select a model for this session`, + { provider: selection.provider, model: selection.model }, + ) + } + const source: MessageSource = { + kind: 'user', + rpcId: request.requestId, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + } + const hasImage = request.content.some(part => part.type === 'image') + const admit = async (): Promise => { + try { + if (hasImage) { + const current = this.agents.selectionFor(agent).current + const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model) + if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { + reject( + 'attachment-error', + `Model "${current.model}" does not support image input.`, + { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + ) + } + } + const content = await durablePromptContent(this.ctx, request.content) + const message: UserMessage = createUserMessage({ content, source }) + if (request.mode === 'steer') agent.steer(message) + else agent.followup(message) + } catch (error) { + if (error instanceof TypertRemoteFailure) throw error + if (error instanceof AttachmentError) { + reject('attachment-error', error.message, { reason: error.code }) + } + reject('agent-busy', 'prompt rejected', { reason: String(error) }) + } + return { accepted: true } + } + return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() + } + + /** + * Read one durable image after proving the Session log references it. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ + async attachment(request: SessionAttachmentRequest): Promise { + let source: SessionReadState + try { + source = await this.readSessionState(request.sessionId) + } catch (error) { + if (error instanceof ApiSessionNotFound) { + reject('session-not-found', error.message, { sessionId: request.sessionId }) + } + reject( + 'internal', + `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + const ref = referencedImage(source.events, String(request.attachmentId)) + if (ref === undefined) { + reject( + 'attachment-error', + 'Image is not referenced by this session.', + { reason: 'ATTACHMENT_NOT_REFERENCED' }, + ) + } + try { + const stored = await this.ctx.attachments.readImage(ref) + return { + attachment: stored.ref, + data: Buffer.from(stored.data).toString('base64'), + } + } catch (error) { + if (error instanceof AttachmentError) { + reject('attachment-error', error.message, { reason: error.code }) + } + reject('internal', 'Unable to read image attachment.', {}) + } + } + + /** + * Mutate one still-pending queue occurrence without resuming a cold Agent. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ + updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { + if (request.action.kind === 'edit' + && request.action.content.some(block => block.type !== 'text')) { + reject( + 'attachment-error', + 'queue edits accept text content only', + { reason: 'QUEUE_EDIT_NON_TEXT' }, + ) + } + const agent = this.ctx.agents.get(request.sessionId) + if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) + } + if (agent === undefined) { + reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + } + const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId) + const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId) + const located = nextTurn === undefined + ? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep } + : { target: 'next-turn' as const, message: nextTurn } + if (located === undefined) { + reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + } + const { target, message } = located + if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) { + reject('steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId }) + } + if (request.action.kind === 'edit') { + agent.inbox.replace(request.itemId, freezeMessage({ + ...message, + content: [...request.action.content], + })) + } else { + agent.inbox.remove(request.itemId) + if (request.action.kind === 'steer') agent.steer(message) + } + return { accepted: true } + } + + /** + * Cancel one live ordinary Agent while retaining pending inbox work. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ + cancel(request: SessionCancelRequest): SessionCancelValue { + const agent = this.ctx.agents.get(request.sessionId) + if (agent === undefined) { + reject( + 'session-not-found', + `session "${request.sessionId}" not found (not attached)`, + { sessionId: request.sessionId }, + ) + } + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) + } + agent.cancel({ kind: 'user' }, { keepInbox: true }) + return { accepted: true } + } + + private async resolveAgent(sessionId: SessionId): Promise { + const found = await this.agents.resolveAgent(sessionId) + if ('error' in found) rejectFailure(found.error) + return found.agent + } + + private rejectCreation(sessionId: SessionId, error: unknown): never { + if (error instanceof ApiSessionPresetConflict) { + reject('agent-preset-conflict', error.message, { + sessionId: error.sessionId, + requestedPreset: error.requestedPreset, + ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), + }) + } + if (error instanceof UnknownPresetError) { + reject('agent-preset-not-found', error.message, { + agentPreset: error.presetId, + available: [...error.available], + }) + } + if (error instanceof PresetMountError) { + reject('agent-preset-invalid', error.message, { + agentPreset: error.presetId, + reason: error.reason, + }) + } + if (error instanceof ApiSessionCwdConflict) { + reject('session-conflict', error.message, { + sessionId: error.sessionId, + requestedCwd: error.requestedCwd, + ...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }), + }) + } + if (error instanceof ApiSessionSubagentOwnership) { + rejectFailure(apiSessionSubagentOwnershipError(error.sessionId)) + } + reject('internal', `failed to create session "${sessionId}": ${String(error)}`, {}) + } + + private async readSessionState(sessionId: SessionId): Promise { + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined) { + return { id: attached.id, header: attached.header, events: [...attached.events] } + } + const inspected = await inspectApiSession(this.ctx, sessionId) + return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } + } + + private async forkWorkspace(source: Pick): Promise { + const workspaces = this.ctx.workspaceRegistry.list() + const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) + if (direct !== undefined || source.header.origin !== 'subagent') return direct + const lineage = await this.ctx.sessionQuery.traceSession(source.id) + for (const ancestor of lineage.ancestors) { + const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id)) + if (workspace !== undefined) return workspace + } + return undefined + } +} + +function rejectFailure(error: { readonly code: string; readonly message: string; readonly details: object }): never { + throw new TypertRemoteFailure(error) +} + +function reject(code: string, message: string, details: object): never { + throw new TypertRemoteFailure({ code, message, details }) +} + +async function durablePromptContent( + ctx: Context, + content: readonly SessionPromptRequest['content'][number][], +): Promise { + if (content.every(part => part.type === 'text')) { + return content.map(part => ({ type: 'text', text: part.text })) + } + const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image')) + let next = 0 + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + // admitEncodedImages returns one reference per image part in order. + : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) +} + +function imageBlockIn( + content: unknown, + match: (ref: ImageAttachmentRef) => boolean, +): ImageAttachmentRef | undefined { + if (!Array.isArray(content)) return undefined + for (const value of content) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown } + if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + const ref = block.attachment as ImageAttachmentRef + if (match(ref)) return ref + } + if (block.type === 'tool-result') { + const nested = imageBlockIn(block.content, match) + if (nested !== undefined) return nested + } + } + return undefined +} + +function imageInEvent( + event: SessionEvent, + match: (ref: ImageAttachmentRef) => boolean, +): ImageAttachmentRef | undefined { + const data = event.data as { + readonly content?: unknown + readonly message?: { readonly content?: unknown } + readonly inserted?: readonly { readonly content?: unknown }[] + readonly chunk?: { readonly type?: unknown; readonly block?: unknown } + } + const direct = imageBlockIn(data.content, match) + if (direct !== undefined) return direct + const message = imageBlockIn(data.message?.content, match) + if (message !== undefined) return message + for (const inserted of data.inserted ?? []) { + const found = imageBlockIn(inserted.content, match) + if (found !== undefined) return found + } + return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end' + ? imageBlockIn([data.chunk.block], match) + : undefined +} + +function referencedImage( + events: readonly SessionEvent[], + attachmentId: string, +): ImageAttachmentRef | undefined { + for (const event of events) { + const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId) + if (found !== undefined) return found + } + return undefined +} + +const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ + +function canonicalClientTimeZone(value: string): string | undefined { + if (value.length === 0 || value.trim() !== value + || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined + try { + return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone + } catch { + return undefined + } +} + +function routeServed(ctx: Context, provider: string): boolean { + return ctx.llm.listProviders().some(entry => entry.id === provider) +} diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts new file mode 100644 index 0000000000..4a28710a05 --- /dev/null +++ b/packages/api/session-controller/src/control.ts @@ -0,0 +1,211 @@ +/** Live Session queue, jobs, and projection state with reconnect baselines. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' +import type { + JsonValue, Session, SessionEvent, SessionEventMap, SessionId, UserMessage, +} from '@deepseek-ai/dsh-session' +import type { + SessionControlBaseline, + SessionControlFrame, + SessionJob, + SessionProjectionsBlock, + SessionProjectionValues, + SessionQueuedItem, +} from './types.ts' + +/** Owns the Host-wide Session control stream. */ +export class SessionControlController { + private readonly streams = new Set() + + /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ + constructor(private readonly ctx: Context) { + ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) }) + ctx.on('session/created', (session) => { + const jobs = this.jobsFor(this.ctx.agents.get(session.id)) + if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs }) + }) + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.onChanged((session, key, value, seq) => { + this.broadcast({ + type: 'projection', + sessionId: session.id, + key, + value: value as JsonValue, + seq, + }) + }) + }) + ctx.inject(['jobs'], (jobsCtx) => { + jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) + }) + ctx.effect(() => () => { + for (const stream of this.streams) stream.end() + this.streams.clear() + }, 'session-controller.control') + } + + /** + * Open one generation of Host-wide live control state. + * @param signal - Remote stream cancellation. + * @returns one complete baseline followed by live replacement frames. + */ + async *control(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const queue = new ControlQueue() + this.streams.add(queue) + try { + yield { type: 'baseline', value: this.baseline() } + yield* queue.iterate(signal) + } finally { + this.streams.delete(queue) + queue.end() + } + } + + private baseline(): SessionControlBaseline { + const sessions = this.ctx.sessions.list() + const queues = Object.create(null) as Record + const jobs = Object.create(null) as Record + for (const session of sessions) { + const agent = this.ctx.agents.get(session.id) + queues[session.id] = agent?.session === session ? queueItems(agent) : [] + jobs[session.id] = this.jobsFor(agent) + } + return { + queues, + jobs, + projections: this.projectionBaseline(sessions), + } + } + + private projectionBaseline( + sessions: readonly Session[], + ): Readonly> { + const registry = this.ctx.get('sessionProjections') + const blocks = Object.create(null) as Record + for (const session of sessions) { + const snapshot = registry?.snapshot(session) + blocks[session.id] = snapshot === undefined + ? { asOfSeq: session.seq - 1, values: {} } + : { + asOfSeq: snapshot.asOfSeq, + // Every projection definition validates its value before snapshot publication. + values: snapshot.values as SessionProjectionValues, + } + } + return blocks + } + + private onSessionEvent(session: Session, event: SessionEvent): void { + if (event.type !== 'agent/inbox/spliced') return + const agent = this.ctx.agents.get(session.id) + if (agent?.session !== session) return + this.broadcast({ + type: 'queue', + sessionId: session.id, + items: queueItems(agent, event.data), + }) + } + + private onJobsChanged(owner: Agent | undefined): void { + if (owner !== undefined) { + this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) }) + return + } + for (const session of this.ctx.sessions.list()) { + this.broadcast({ + type: 'jobs', + sessionId: session.id, + jobs: this.jobsFor(this.ctx.agents.get(session.id)), + }) + } + } + + private jobsFor(agent: Agent | undefined): SessionJob[] { + const jobs = this.ctx.get('jobs') + return jobs === undefined ? [] : jobs.list(agent).map(jobView) + } + + private broadcast(frame: SessionControlFrame): void { + for (const stream of this.streams) stream.push(frame) + } +} + +class ControlQueue { + private readonly buffer: SessionControlFrame[] = [] + private wake: (() => void) | undefined + private done = false + + push(frame: SessionControlFrame): void { + if (this.done) return + this.buffer.push(frame) + const wake = this.wake + this.wake = undefined + wake?.() + } + + end(): void { + if (this.done) return + this.done = true + const wake = this.wake + this.wake = undefined + wake?.() + } + + async *iterate(signal: AbortSignal): AsyncIterable { + const onAbort = (): void => { this.end() } + signal.addEventListener('abort', onAbort, { once: true }) + try { + while (!this.done && !signal.aborted) { + const frame = this.buffer.shift() + if (frame !== undefined) { + yield frame + continue + } + await new Promise((resolve) => { this.wake = resolve }) + } + while (this.buffer.length > 0 && !signal.aborted) yield this.buffer.shift() as SessionControlFrame + } finally { + signal.removeEventListener('abort', onAbort) + this.end() + } + } +} + +function queueItems( + agent: Agent, + splice?: SessionEventMap['agent/inbox/spliced'], +): SessionQueuedItem[] { + const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => { + const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep + return splice?.target === target + ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted) + : messages + } + return [ + ...project('next-turn').map(message => ({ + id: message.id, + placement: 'queued' as const, + message: { id: message.id, content: message.content as unknown as JsonValue[] }, + })), + ...project('next-step').map(message => ({ + id: message.id, + placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, + message: { id: message.id, content: message.content as unknown as JsonValue[] }, + })), + ] +} + +function jobView(job: JobSnapshot): SessionJob { + return { + id: job.id, + kind: job.kind, + label: job.label, + status: job.status, + ...(job.detail === undefined ? {} : { detail: job.detail }), + startedAt: job.startedAt, + ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), + } +} diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts new file mode 100644 index 0000000000..ab7bce2525 --- /dev/null +++ b/packages/api/session-controller/src/history.ts @@ -0,0 +1,443 @@ +/** Cold Session history pagination and live-event source. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import type { + SessionAddress, + SessionEventEntry, + SessionFollowRequest, + SessionFollowFrame, + SessionPage, + SessionPageRequest, + SessionProjectionsBlock, + SessionProjectionValues, + SessionToolCallView, + SessionToolView, + SessionWireEvent, +} from './types.ts' + +const DEFAULT_MAX_MESSAGES = 50 +const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) + +interface ToolCallData { + readonly callId: string + readonly name: string + readonly arguments: string +} + +type SessionSource = + | { readonly kind: 'attached'; readonly session: Session } + | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] } + +interface BufferedEvent { + readonly session: Session + readonly event: SessionEvent +} + +/** Implements cold-safe history operations delegated by the Session Controller. */ +export class SessionHistoryController { + private readonly closeFollowers = new Set<() => void>() + + /** @param ctx - Host context carrying Session, persistence, presenter, and projection services. */ + constructor(private readonly ctx: Context) { + ctx.effect(() => () => { + for (const close of this.closeFollowers) close() + this.closeFollowers.clear() + }, 'session-controller.history') + } + + /** + * Read one message-aligned history page without activating an Agent. + * @param request - durable address and backwards-page cursor. + * @param signal - caller cancellation for persistence and preset reads. + * @returns a contiguous event page and a projection baseline on tail reads. + */ + async page(request: SessionPageRequest, signal: AbortSignal): Promise { + validatePageRequest(request) + const source = await this.sourceFor(request.address, signal) + signal.throwIfAborted() + const sourceLog = sourceEvents(source) + const sourceCursor = sourceLog.at(-1)?.seq ?? -1 + if (request.throughSeq > sourceCursor) { + reject( + 'bad-request', + `session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`, + {}, + ) + } + const events = sourceLog.filter(event => event.seq <= request.throughSeq) + if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) { + reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {}) + } + const scope = await this.presenterScopeFor(addressId(request.address), source, events) + signal.throwIfAborted() + const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES) + const argsFor = (callId: string) => backscanArgs(page.events, callId) + const entries = page.events.map(event => entryFor(this.ctx, event, argsFor, scope)) + const projections = request.beforeSeq === undefined + ? this.projectionsFor(request.address, source, events) + : undefined + return { + events: entries, + hasMore: page.hasMore, + ...(projections === undefined ? {} : { projections }), + } + } + + /** + * Follow events appended after an initial cursor on one durable address. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - stream cancellation owned by the Remote carrier. + * @returns an opened cursor followed by gap-free event frames. + */ + async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { + validateFollowRequest(request) + const { address, afterSeq } = request + const target = addressId(address) + const buffered: BufferedEvent[] = [] + const openCalls = new Map() + let fallbackEvents: readonly SessionEvent[] = [] + const argsFor = (callId: string): { readonly name: string; readonly args: unknown } | undefined => ( + openCalls.get(callId) ?? backscanArgs(fallbackEvents, callId) + ) + let wake: (() => void) | undefined + const notify = (): void => { + const resume = wake + wake = undefined + resume?.() + } + const follower = { closed: false } + const close = (): void => { + follower.closed = true + notify() + } + this.closeFollowers.add(close) + const disposeEvent = this.ctx.on('session/event', (session, event) => { + if (session.id !== target) return + buffered.push({ session, event }) + notify() + }, { global: true }) + const disposeCreated = this.ctx.on('session/created', (session) => { + if (session.id !== target) return + // Session construction appends session/end-seed before attachment, so the + // marker has no session/event notification. Earlier session/created listeners + // may publish later setup events first; this suffix must precede those notifications. + const suffix = session.events.slice(session.firstLiveSeq).map(event => ({ session, event })) + buffered.unshift(...suffix) + notify() + }, { global: true }) + const onAbort = (): void => { notify() } + signal.addEventListener('abort', onAbort, { once: true }) + try { + const source = await this.sourceFor(address, signal) + const events = [...sourceEvents(source)] + fallbackEvents = events + const scope = await this.presenterScopeFor(target, source, events) + signal.throwIfAborted() + const cursor = events.at(-1)?.seq ?? -1 + if (afterSeq !== undefined && afterSeq > cursor) { + reject('bad-request', `session event resume seq ${String(afterSeq)} is past cursor ${String(cursor)}`, {}) + } + let nextSeq = (afterSeq ?? cursor) + 1 + yield { type: 'opened', cursor } + if (afterSeq !== undefined) { + for (const event of events) { + if (event.seq < nextSeq) continue + if (event.seq !== nextSeq) { + reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {}) + } + nextSeq++ + yield { type: 'event', ...entryFor(this.ctx, event, argsFor, scope) } + } + } + while (!follower.closed && !signal.aborted) { + const item = buffered.shift() + if (item === undefined) { + await new Promise((resolve) => { wake = resolve }) + continue + } + if (item.event.seq < nextSeq) continue + if (item.event.seq !== nextSeq) { + reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {}) + } + nextSeq++ + if (item.event.type === 'tool/call') { + const data = item.event.data as ToolCallData + const call = parseToolCall(data) + /* v8 ignore next -- malformed durable tool arguments intentionally skip the live presentation cache. */ + if (call !== undefined) openCalls.set(data.callId, call) + } else if (item.event.type === 'turn/end') { + openCalls.clear() + } + if (item.event.type === 'tool/result' + && !openCalls.has(item.event.data.message.source.callId)) { + fallbackEvents = item.session.events + } + const liveScope: Agent | undefined = this.ctx.get('agents')?.get(target) + const entry = entryFor(this.ctx, item.event, argsFor, liveScope ?? scope) + yield { type: 'event', ...entry } + } + } finally { + this.closeFollowers.delete(close) + signal.removeEventListener('abort', onAbort) + disposeCreated() + disposeEvent() + } + } + + private async sourceFor(address: SessionAddress, signal: AbortSignal): Promise { + const sessionId = addressId(address) + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined) { + validateAddress(address, attached.header, attached.events) + return { kind: 'attached', session: attached } + } + const persistence = this.ctx.get('sessionPersistence') + if (persistence === undefined) { + reject('internal', 'session persistence is not configured', {}) + } + signal.throwIfAborted() + const header = (await persistence.list(signal)).find(candidate => candidate.id === sessionId) + if (header === undefined || header.cwd === undefined) rejectNotFound(address) + const inspected: SessionInspection = await persistence.inspect(sessionId, signal) + signal.throwIfAborted() + if (inspected.meta.cwd === undefined) rejectNotFound(address) + validateAddress(address, inspected.meta, inspected.events) + return { kind: 'detached', header: inspected.meta, events: inspected.events } + } + + private async presenterScopeFor( + sessionId: SessionId, + source: SessionSource, + events: readonly SessionEvent[], + ): Promise { + const live = this.ctx.get('agents')?.get(sessionId) + if (live !== undefined) return live + const presets = this.ctx.get('agentPresets') + if (presets === undefined) return undefined + const session = source.kind === 'attached' + ? { header: source.session.header, events } + : { header: source.header, events } + try { + return await presets.standingKeyFor(resolveSessionPreset(session)) + } catch { + return undefined + } + } + + private projectionsFor( + address: SessionAddress, + source: SessionSource, + events: readonly SessionEvent[], + ): SessionProjectionsBlock | undefined { + const registry = this.ctx.get('sessionProjections') + if (registry === undefined) return undefined + try { + const throughSeq = events.at(-1)?.seq ?? -1 + const snapshot = source.kind === 'attached' && source.session.seq - 1 === throughSeq + ? registry.snapshot(source.session) + : registry.restore({}, events, 0).snapshot + return { + asOfSeq: snapshot.asOfSeq, + // Projection definitions validate whole JSON values before snapshot publication. + values: snapshot.values as SessionProjectionValues, + } + } catch (error) { + if (address.kind === 'session') throw error + this.ctx.logger.warn(`session.page: projections for "${address.childSessionId}" failed: ${String(error)}`) + return undefined + } + } +} + +function validatePageRequest(request: SessionPageRequest): void { + if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) { + reject('bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) + } + if (request.beforeSeq !== undefined + && (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) { + reject('bad-request', 'beforeSeq must be a non-negative safe integer', {}) + } + if (request.maxMessages !== undefined + && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { + reject('bad-request', 'maxMessages must be a positive safe integer', {}) + } +} + +function validateFollowRequest(request: SessionFollowRequest): void { + if (request.afterSeq !== undefined + && (!Number.isSafeInteger(request.afterSeq) || request.afterSeq < -1)) { + reject('bad-request', 'afterSeq must be an integer greater than or equal to -1', {}) + } +} + +function addressId(address: SessionAddress): SessionId { + return address.kind === 'session' ? address.sessionId : address.childSessionId +} + +function validateAddress( + address: SessionAddress, + header: SessionHeader, + events: readonly SessionEvent[], +): void { + if (address.kind === 'session') { + if (header.origin === 'subagent') { + reject('agent-busy', 'subagent Sessions require their durable parent address', { + reason: 'use subagent delivery for this child session', + }) + } + return + } + if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) { + reject('subagent-unauthorized', 'subagent does not belong to the supplied parent', { + childSessionId: address.childSessionId, + }) + } + let descriptor + try { + descriptor = foldSubagentDescriptor(events.slice(header.seedLength ?? 0)) + } catch { + reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + reason: 'corrupt', + }) + } + if (descriptor === undefined) { + reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + reason: 'unsupported', + }) + } + if (descriptor.mode !== address.mode) { + reject('subagent-unauthorized', 'subagent mode does not match the supplied address', { + childSessionId: address.childSessionId, + }) + } +} + +function rejectNotFound(address: SessionAddress): never { + if (address.kind === 'session') { + reject('session-not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId }) + } + reject('subagent-not-found', 'subagent is unavailable', { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + }) +} + +function reject(code: string, message: string, details: object): never { + throw new TypertRemoteFailure({ code, message, details }) +} + +function sourceEvents(source: SessionSource): readonly SessionEvent[] { + return source.kind === 'attached' ? source.session.events : source.events +} + +function paginate( + events: readonly SessionEvent[], + beforeSeq: number | undefined, + maxMessages: number, +): { readonly events: SessionEvent[]; readonly hasMore: boolean } { + const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq) + let count = 0 + let cut = 0 + for (let index = window.length - 1; index >= 0; index--) { + const event = window[index] as SessionEvent + if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue + count++ + const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs + let groupStart = event.seq + if (sources !== undefined) { + for (const source of sources) groupStart = Math.min(groupStart, source) + } + if (count >= maxMessages) { + cut = groupStart + break + } + } + return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 } +} + +function entryFor( + ctx: Context, + event: SessionEvent, + argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined, + scope?: ScopeKey, +): SessionEventEntry { + const view = viewFor(ctx, event, argsFor, scope) + return { + // Session.append validates and freezes event data as JSON before publication. + event: event as unknown as SessionWireEvent, + ...(view === undefined ? {} : { view }), + } +} + +function viewFor( + ctx: Context, + event: SessionEvent, + argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined, + scope?: ScopeKey, +): SessionToolView | undefined { + if (event.type !== 'tool/call' && event.type !== 'tool/result') return undefined + const tools = ctx.get('tools') + /* v8 ignore next -- deployments without the optional Tools service omit presentation metadata. */ + if (tools === undefined) return undefined + try { + if (event.type === 'tool/call') { + const data = event.data as ToolCallData + const view: ToolCallView | undefined = tools.get(data.name, scope)?.presentCall?.(JSON.parse(data.arguments)) + return view === undefined ? undefined : { for: 'call', view: jsonView(view) } + } + const [result] = event.data.message.content + const call = argsFor(event.data.message.source.callId) + if (call === undefined) return undefined + const view: ToolResultView | undefined = tools.get(call.name, scope)?.presentResult?.(call.args, { + content: result.content, + isError: result.isError === true, + ...(event.data.meta === undefined ? {} : { meta: event.data.meta }), + }) + return view === undefined ? undefined : { for: 'result', view: jsonView(view) } + } catch (error) { + ctx.logger.warn(`session: presenter failed for ${event.type}: ${String(error)}`) + } + return undefined +} + +function backscanArgs( + events: readonly SessionEvent[], + callId: string, +): { readonly name: string; readonly args: unknown } | undefined { + for (let index = events.length - 1; index >= 0; index--) { + const event = events[index] as SessionEvent + if (event.type !== 'tool/call') continue + const data = event.data as ToolCallData + if (data.callId !== callId) continue + return parseToolCall(data) + } + return undefined +} + +function parseToolCall(data: ToolCallData): { readonly name: string; readonly args: unknown } | undefined { + try { + return { name: data.name, args: JSON.parse(data.arguments) } + } catch { + return undefined + } +} + +function jsonView(view: ToolCallView): SessionToolCallView +function jsonView(view: ToolResultView): ToolResultView +function jsonView(view: ToolCallView | ToolResultView): SessionToolCallView | ToolResultView { + const encoded = JSON.stringify(view) + return JSON.parse(encoded) as SessionToolCallView | ToolResultView +} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts new file mode 100644 index 0000000000..b4b6b4c53a --- /dev/null +++ b/packages/api/session-controller/src/index.ts @@ -0,0 +1,295 @@ +/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { errorChain } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { + ApiSessionAgentController, + inspectApiSession, + type ApiSessionAgentResult, +} from './agent.ts' +import { SessionCommandController } from './commands.ts' +import { SessionControlController } from './control.ts' +import { SessionHistoryController } from './history.ts' +import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' +import type { + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionControlFrame, + SessionCreateRequest, + SessionCreateValue, + SessionFollowFrame, + SessionFollowRequest, + SessionForkRequest, + SessionForkValue, + SessionListRequest, + SessionListValue, + SessionModels, + SessionModelsRequest, + SessionPage, + SessionPageRequest, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSearchRequest, + SessionSearchValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, +} from './types.ts' + +export type * from './types.ts' +export { ApiSessionNotFound } from './agent.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host Session business API and Remote namespace owner. */ + sessionController: SessionController + } +} + +/** Session Controller deployment policy. */ +export interface Config { + /** Maximum cold Session artifact size read to determine blankness. */ + readonly coldBlankProbeMaxBytes?: number +} + +/** Host service backing the generated `ctx.remote.session` namespace. */ +export class SessionController extends TypertRemoteService { + static inject = [ + 'agentDefaultModel', + 'agents', + 'attachments', + 'llm', + 'sessions', + 'sessionQuery', + 'tools', + 'typert', + 'workspaceRegistry', + ] + + static Config: z = z.object({ + coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), + }) + + private readonly agents: ApiSessionAgentController + private readonly commands: SessionCommandController + private readonly controlState: SessionControlController + private readonly history: SessionHistoryController + private readonly listState: ApiSessionList + + /** + * @param ctx - Host context containing the Session capability assembly. + * @param config - cold-list read policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'sessionController', { namespace: 'session' }) + this.agents = new ApiSessionAgentController(ctx) + this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) + this.controlState = new SessionControlController(ctx) + this.history = new SessionHistoryController(ctx) + this.listState = new ApiSessionList( + ctx, + config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, + ) + + ctx.on('session/created', (session) => { + ctx.emit('api-session/added', this.listState.summaryFor(session)) + }) + ctx.on('session/disposed', (session) => { + ctx.emit('api-session/removed', session.id) + }) + ctx.on('agent/status', ({ agent, status }) => { + ctx.emit('api-session/status', agent.id, status === 'running') + }) + ctx.on('agent/error', ({ agent, error }) => { + ctx.emit('api-session/error', agent.id, errorChain(error)) + }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'user/message' || event.data.source.kind !== 'user') return + ctx.emit('api-session/activity', session.id, event.time) + }) + } + + /** + * Resolve or resume one ordinary Session for another Host API domain. + * @param sessionId - Session identity whose Agent owns the operation. + * @returns the live Agent or the stable Session-domain failure. + */ + resolveAgent(sessionId: SessionId): Promise { + return this.agents.resolveAgent(sessionId) + } + + /** + * Inspect one attached or persisted Session without activating its Agent. + * @param sessionId - durable Session identity. + * @param signal - optional caller cancellation for persistence reads. + * @returns the current attached state or persisted header and event prefix. + */ + inspect( + sessionId: SessionId, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined) { + return Promise.resolve({ meta: attached.header, events: [...attached.events] }) + } + return inspectApiSession(this.ctx, sessionId, signal) + } + + /** + * Read all visible Session rows without resuming an Agent. + * @param _request - reserved empty list request. + * @param signal - cancellation for persistence reads. + * @returns visible Session summaries ordered by activity. + */ + @Remote('list') + async list(_request: SessionListRequest, signal: AbortSignal): Promise { + return { items: await this.listState.list(signal) } + } + + /** + * Search visible Session content without resuming an Agent. + * @param request - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ + @Remote('search') + search(request: SessionSearchRequest, signal: AbortSignal): Promise { + return this.listState.search(request.query, signal) + } + + /** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ + @Remote('create') + create(request: SessionCreateRequest): Promise { + return this.commands.create(request) + } + + /** + * Read model choices after explicitly resuming the addressed Session. + * @param request - Session whose model state is requested. + * @returns the current selection and available model groups. + */ + @Remote('models') + models(request: SessionModelsRequest): Promise { + return this.commands.models(request) + } + + /** + * Select one Session-local model after explicitly resuming the Session. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session. + */ + @Remote('selectModel') + selectModel(request: SessionSelectModelRequest): Promise { + return this.commands.selectModel(request) + } + + /** + * Rename one Session after explicitly resuming it. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ + @Remote('rename') + rename(request: SessionRenameRequest): Promise { + return this.commands.rename(request) + } + + /** + * Fork one cold-readable completed-turn prefix into a new Session. + * @param request - source Session and optional event anchor. + * @returns the new Session identity. + */ + @Remote('fork') + fork(request: SessionForkRequest): Promise { + return this.commands.fork(request) + } + + /** + * Admit one prompt after explicitly resuming its Session. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @param signal - caller cancellation before prompt admission begins. + * @returns acknowledgement that the Agent accepted the prompt. + */ + @Remote('prompt') + prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() + return this.commands.prompt(request) + } + + /** + * Read one image proven reachable from the addressed Session log. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ + @Remote('attachment') + attachment(request: SessionAttachmentRequest): Promise { + return this.commands.attachment(request) + } + + /** + * Mutate one still-pending queue occurrence on a live Agent. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ + @Remote('updateQueue') + updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { + return this.commands.updateQueue(request) + } + + /** + * Cancel one active Agent turn without dropping its pending inbox. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ + @Remote('cancel') + cancel(request: SessionCancelRequest): SessionCancelValue { + return this.commands.cancel(request) + } + + /** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, backward cursor, and page budget. + * @param signal - cancellation for persistence and presentation reads. + * @returns one chronological page and optional latest projections. + */ + @Remote('page') + page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) + } + + /** + * Follow one Session log from its opening or resume cursor. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns an opened cursor followed by gap-free event frames. + */ + @Remote({ mode: 'stream' }) + follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { + return this.history.follow(request, signal) + } + + /** + * Stream a complete live-control baseline followed by replacement frames. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns one complete baseline followed by live replacement frames. + */ + @Remote({ mode: 'stream' }) + control(signal: AbortSignal): AsyncIterable { + return this.controlState.control(signal) + } + +} + +export { buildModelCatalog } from './catalog.ts' +export default SessionController diff --git a/packages/api/session-controller/src/invariant.ts b/packages/api/session-controller/src/invariant.ts new file mode 100644 index 0000000000..d225d978ff --- /dev/null +++ b/packages/api/session-controller/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-session-controller/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-api-session-controller' + +/** Cordis companion plugin name. */ +export const name = 'api-session-controller-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: every page and frame is checked against the addressed durable Session. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts new file mode 100644 index 0000000000..845e7fae86 --- /dev/null +++ b/packages/api/session-controller/src/list.ts @@ -0,0 +1,408 @@ +/** Cold-safe Session list and search projection. */ + +import { stat } from 'node:fs/promises' +import type { Context } from '@deepseek-ai/cordis' +import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets' +import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type {} from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-session-projection-cache' +import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { z } from 'zod' +import { + SESSION_SEARCH_RESULT_LIMIT, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, +} from './types.ts' +import type { + SessionListMetadata, SessionProjectionsBlock, SessionProjectionValues, SessionSearchItem, + SessionSearchValue, SessionSummary, +} from './types.ts' + +/** Default maximum artifact size eligible for one cold blankness read. */ +export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 + +const COLD_SUMMARY_BATCH_SIZE = 16 +const SEARCH_PROVIDER_CALL_LIMIT = 100 +const SESSION_SEARCH_QUERY_MAX_CHARS = 500 +const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) + +const sessionListMetadataSchema: z.ZodType = z.object({ + blank: z.boolean(), + lastPromptAt: z.number().nullable(), +}) + +const imageLimitsSchema = z.object({ + maxImageBytes: z.number().int().positive(), + maxImagesPerMessage: z.number().int().positive(), + maxMessageImageBytes: z.number().int().positive(), + maxImagePixels: z.number().int().positive(), + maxImageDimension: z.number().int().positive(), + mediaTypes: z.array(z.string()), +}) as unknown as z.ZodType + +/** + * Advance the Session-list metadata projection by one committed event. + * @param state - metadata before the event. + * @param event - next committed Session event. + * @returns the original or advanced metadata value. + */ +export function applySessionListMetadata( + state: SessionListMetadata, + event: SessionEvent, +): SessionListMetadata { + const blank = state.blank && event.type !== 'turn/start' + const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user' + ? event.time + : state.lastPromptAt + return blank === state.blank && lastPromptAt === state.lastPromptAt + ? state + : { blank, lastPromptAt } +} + +/** + * Fold exact list metadata for an attached Session. + * @param events - complete attached Session event log. + * @returns metadata derived from the event prefix. + */ +export function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata { + let state: SessionListMetadata = { blank: true, lastPromptAt: null } + for (const event of events) state = applySessionListMetadata(state, event) + return state +} + +/** + * Return the longest prefix containing at most `maximum` Unicode code points. + * @param value - source text. + * @param maximum - maximum number of Unicode code points. + * @returns the source text or its longest allowed prefix. + */ +export function truncateUnicodeCodePoints(value: string, maximum: number): string { + let count = 0 + let end = 0 + for (const codePoint of value) { + if (count === maximum) return value.slice(0, end) + count++ + end += codePoint.length + } + return value +} + +/** Owns list projection registration, cold summaries, and authorized search. */ +export class ApiSessionList { + /** + * @param ctx - Host context carrying Session, persistence, and projection services. + * @param coldBlankProbeMaxBytes - maximum physical artifact size read to verify cold blankness. + */ + constructor( + private readonly ctx: Context, + private readonly coldBlankProbeMaxBytes: number, + ) { + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ + key: 'sessionListMetadata', + stateSchema: sessionListMetadataSchema, + init: () => ({ blank: true, lastPromptAt: null }), + apply: applySessionListMetadata, + wire: { viewSchema: sessionListMetadataSchema, view: state => state }, + stateVersion: 1, + }) + }) + ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'imageLimits', null>({ + key: 'imageLimits', + stateSchema: z.null(), + init: () => null, + apply: state => state, + wire: { + viewSchema: imageLimitsSchema, + view: () => projectionCtx.attachments.imageLimits, + }, + stateVersion: 1, + }) + }) + } + + /** + * Build one current attached-Session summary. + * @param session - attached Session to summarize. + * @returns current list metadata and available projections. + */ + summaryFor(session: Session): SessionSummary { + const metadata = sessionListMetadata(session.events) + const projections = this.projectionsFor(session.header, session) + return { + sessionId: session.id, + updatedAt: updatedAt(session.header, metadata), + running: this.ctx.agents.get(session.id)?.status === 'running', + blank: metadata.blank, + ...listFields(session.header, session.events), + ...(projections === undefined ? {} : { projections }), + } + } + + /** + * Read every visible attached and persisted Session without activating an Agent. + * @param signal - optional cancellation for persistence reads. + * @returns visible Session summaries ordered by activity. + */ + async list(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const items = this.ctx.sessions.list().map(session => this.summaryFor(session)) + const attached = new Set(items.map(item => item.sessionId)) + const persistence = this.ctx.get('sessionPersistence') + if (persistence !== undefined) { + const cold = (await persistence.list(signal)) + .filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) + signal?.throwIfAborted() + for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) { + const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE) + .map(async (meta) => { + const projections = this.projectionsFor(meta, undefined) + const summary = await summarizeCold( + this.ctx, + persistence, + meta, + projections?.values.sessionListMetadata, + this.coldBlankProbeMaxBytes, + signal, + ) + const raced = this.ctx.sessions.get(meta.id) + if (raced !== undefined) return this.summaryFor(raced) + return { ...summary, ...(projections === undefined ? {} : { projections }) } + })) + const summaries = settled.map((result) => { + if (result.status === 'rejected') throw result.reason + return result.value + }) + signal?.throwIfAborted() + items.push(...summaries) + } + } + items.sort((left, right) => right.updatedAt - left.updatedAt) + return items + } + + /** + * Search current visible message content without activating any matching Session. + * @param query - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ + async search(query: string, signal: AbortSignal): Promise { + const normalizedQuery = normalizeSearchQuery(query) + signal.throwIfAborted() + const provider = this.ctx.get('sessionQuery') + if (provider === undefined) { + reject( + 'internal', + 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query', + {}, + ) + } + try { + const visible = await this.list(signal) + signal.throwIfAborted() + if (visible.length === 0) return { items: [], hasMore: false } + const visibleIds = new Set(visible.map(item => item.sessionId)) + const authorized: SessionSearchItem[] = [] + const acceptedIds = new Set() + const seenCursors = new Set() + let cursor: SessionSearchCursor | undefined + let providerCalls = 0 + let pageLimit = SESSION_SEARCH_RESULT_LIMIT + while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) { + signal.throwIfAborted() + if (providerCalls >= SEARCH_PROVIDER_CALL_LIMIT) { + throw new Error(`session search provider exceeded the ${SEARCH_PROVIDER_CALL_LIMIT}-call work budget`) + } + providerCalls++ + const requestedCursor = cursor + const requestedLimit = pageLimit + let page + try { + page = await provider.searchSessions({ + query: normalizedQuery, + eventFilters: [ + { kind: 'type', values: ['user/message', 'assistant/message'] }, + { kind: 'surface', values: ['current'] }, + ], + limit: requestedLimit, + ...(requestedCursor === undefined ? {} : { cursor: requestedCursor }), + }, { signal }) + signal.throwIfAborted() + } catch (error: unknown) { + signal.throwIfAborted() + if (requestedCursor === undefined + && error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_INVALID_LIMIT' + && requestedLimit > 1) { + pageLimit = Math.max(1, Math.floor(requestedLimit / 2)) + continue + } + if (requestedCursor !== undefined + && error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_STALE_CURSOR') { + authorized.length = 0 + acceptedIds.clear() + seenCursors.clear() + cursor = undefined + continue + } + throw error + } + if (page.items.length > requestedLimit) { + throw new Error(`session search provider returned ${String(page.items.length)} items; maximum is ${String(requestedLimit)}`) + } + for (const hit of page.items) { + if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue + if (!visibleIds.has(hit.header.id) + || hit.bestMatch.sessionId !== hit.header.id + || hit.bestMatch.surface !== 'current' + || !MESSAGE_TYPES.has(hit.bestMatch.type) + || acceptedIds.has(hit.header.id)) continue + acceptedIds.add(hit.header.id) + authorized.push({ + sessionId: hit.header.id, + snippet: truncateUnicodeCodePoints(hit.bestMatch.snippet, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS), + }) + } + if (page.nextCursor !== undefined) { + if (seenCursors.has(page.nextCursor)) { + throw new Error('session search provider repeated a continuation cursor') + } + seenCursors.add(page.nextCursor) + } + if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || page.nextCursor === undefined) break + cursor = page.nextCursor + } + return { + items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT), + hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT, + } + } catch (error: unknown) { + signal.throwIfAborted() + if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') { + reject('cancelled', 'session search was aborted', {}) + } + reject('internal', `session search failed: ${String(error)}`, {}) + } + } + + private projectionsFor( + header: SessionHeader, + session: Session | undefined, + ): SessionProjectionsBlock | undefined { + try { + const block = session === undefined + ? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header) + : this.ctx.get('sessionProjections')?.snapshot(session) + return block !== undefined && Object.keys(block.values).length > 0 + ? { + asOfSeq: block.asOfSeq, + // Projection definitions validate whole JSON values before snapshot publication. + values: block.values as SessionProjectionValues, + } + : undefined + } catch (error) { + this.ctx.logger.warn( + `api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`, + ) + return undefined + } + } +} + +function normalizeSearchQuery(query: string): string { + const normalized = query.trim() + if (normalized.length === 0) { + reject('bad-request', 'session search query must not be empty', {}) + } + if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) { + reject( + 'bad-request', + `session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`, + {}, + ) + } + if (normalized.includes('\0')) { + reject('bad-request', 'session search query must not contain NUL', {}) + } + return normalized +} + +function reject(code: string, message: string, details: object): never { + throw new TypertRemoteFailure({ code, message, details }) +} + +function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number { + return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0) +} + +function listFields(header: SessionHeader, events: readonly SessionEvent[] = []): { + readonly parentSessionId?: SessionId + readonly origin?: 'subagent' + readonly cwd?: string + readonly agentPreset?: string +} { + const agentPreset = resolveSessionPreset({ header, events }) + return { + ...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }), + ...(header.origin === undefined ? {} : { origin: header.origin }), + ...(header.cwd === undefined ? {} : { cwd: header.cwd }), + ...(agentPreset === undefined ? {} : { agentPreset }), + } +} + +async function summarizeCold( + ctx: Context, + persistence: SessionPersistence, + header: SessionHeader, + metadata: SessionListMetadata | undefined, + blankProbeMaxBytes: number, + signal?: AbortSignal, +): Promise { + const probed = metadata?.blank === false + ? undefined + : await probeColdMetadata(ctx, persistence, header, blankProbeMaxBytes, signal) + return { + sessionId: header.id, + updatedAt: updatedAt(header, probed ?? metadata), + running: false, + blank: metadata?.blank === false ? false : probed?.blank ?? false, + ...listFields(header), + } +} + +async function probeColdMetadata( + ctx: Context, + persistence: SessionPersistence, + header: SessionHeader, + maxBytes: number, + signal?: AbortSignal, +): Promise { + if (maxBytes === 0) return undefined + signal?.throwIfAborted() + const location = persistence.locate(header) + if (location === undefined) return undefined + let size: number + try { + size = (await stat(location.path)).size + } catch { + signal?.throwIfAborted() + return undefined + } + if (size > maxBytes) return undefined + try { + const { events } = await persistence.readFrom(header.id, 0, signal) + signal?.throwIfAborted() + return sessionListMetadata(events) + } catch (error) { + signal?.throwIfAborted() + ctx.logger.warn( + `api-session.list: blank probe for "${header.id}" failed; serving it as visible: ${String(error)}`, + ) + return undefined + } +} diff --git a/packages/api/session-controller/src/remote-events.ts b/packages/api/session-controller/src/remote-events.ts new file mode 100644 index 0000000000..94d194d72a --- /dev/null +++ b/packages/api/session-controller/src/remote-events.ts @@ -0,0 +1,13 @@ +/** Session Controller events forwarded unchanged through the Remote Event carrier. */ +export const SESSION_CONTROLLER_REMOTE_EVENTS = [ + 'api-session/activity', + 'api-session/added', + 'api-session/error', + 'api-session/removed', + 'api-session/status', +] as const + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteEventSelection extends + Record {} +} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts new file mode 100644 index 0000000000..56e0d7d91a --- /dev/null +++ b/packages/api/session-controller/src/types.ts @@ -0,0 +1,474 @@ +/** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ + +import type { + AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, +} from '@deepseek-ai/dsh-attachment' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { + DiffCallView, + GenericCallView, + TerminalCallView, + ToolResultView, +} from '@deepseek-ai/dsh-tools/presentation' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Host state persisted for cold Session list summaries. */ + sessionListMetadata: SessionListMetadata + /** Host state for the boot-constant image-limit view. */ + imageLimits: null + } + interface SessionProjectionMap { + /** Persisted facts used to summarize a Session without activating it. */ + sessionListMetadata: SessionListMetadata + /** Image-intake limits enforced by the Session prompt endpoint. */ + imageLimits: ImageAttachmentLimits + } +} + +/** Persisted hints used to summarize a cold Session. */ +export interface SessionListMetadata { + /** Whether the folded prefix contains no turn. */ + readonly blank: boolean + /** Latest human-authored prompt time in the folded prefix. */ + readonly lastPromptAt: number | null +} + +/** Projection values and the durable event position they represent. */ +export interface SessionProjectionsBlock { + readonly asOfSeq: number + /** Provider-validated values across the merge-extensible projection key space. */ + readonly values: SessionProjectionValues +} + +/** Typed known projections plus JSON-safe values contributed outside this compilation face. */ +export type SessionProjectionValues = Partial + & Readonly> + +/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ +export type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } + +/** Complete model selection for one Session. */ +export interface ModelSelection { + readonly provider: string + readonly model: string + readonly reasoningEffort?: string +} + +/** One adapter-owned reasoning effort for an exact model route. */ +export interface ModelReasoningEffort { + readonly id: string + readonly name: string + readonly description?: string +} + +/** Selectable reasoning metadata for one exact model route. */ +export interface ModelReasoning { + readonly efforts: readonly ModelReasoningEffort[] + readonly defaultEffort?: string +} + +/** One model displayed inside its provider group. */ +export interface ModelCatalogModel { + readonly id: string + readonly name: string + readonly description?: string + readonly reasoning?: ModelReasoning +} + +/** One provider and its successfully loaded model catalog. */ +export interface ModelProviderGroup { + readonly id: string + readonly name: string + readonly models: readonly ModelCatalogModel[] +} + +/** One provider whose model catalog lookup failed. */ +export interface ModelCatalogFailure { + readonly id: string + readonly name: string + readonly message: string +} + +/** Detached model-directory snapshot for one Session. */ +export interface SessionModels { + readonly current: ModelSelection + readonly routable: boolean + readonly groups: readonly ModelProviderGroup[] + readonly failures: readonly ModelCatalogFailure[] +} + +/** One client-requested mutation of a still-pending queue item. */ +export type QueueAction = + | { readonly kind: 'edit'; readonly content: readonly ContentBlock[] } + | { readonly kind: 'remove' } + | { readonly kind: 'steer' } + +/** One Session list entry. */ +export interface SessionSummary { + readonly sessionId: SessionId + readonly updatedAt: number + readonly running: boolean + readonly blank: boolean + readonly parentSessionId?: SessionId + readonly origin?: 'subagent' + readonly cwd?: string + readonly agentPreset?: string + readonly projections?: SessionProjectionsBlock +} + +/** One session-content search result. */ +export interface SessionSearchItem { + readonly sessionId: SessionId + readonly snippet: string +} + +/** Maximum number of Sessions returned by one search. */ +export const SESSION_SEARCH_RESULT_LIMIT = 20 + +/** Maximum search snippet length in Unicode code points. */ +export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 + +/** Error details returned by Session Remote methods. */ +export interface SessionErrorDetailsMap { + 'bad-request': Record + cancelled: Record + 'session-not-found': { readonly sessionId: SessionId } + 'model-unavailable': { readonly provider: string; readonly model: string } + 'session-conflict': { + readonly sessionId: SessionId + readonly requestedCwd: string + readonly existingCwd?: string + } + 'invalid-time-zone': { readonly value: string } + 'workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string } + 'workspace-not-found': { readonly workspaceId: string } + 'agent-preset-conflict': { + readonly sessionId: SessionId + readonly requestedPreset: string + readonly existingPreset?: string + } + 'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] } + 'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string } + 'agent-busy': { readonly reason: string } + 'attachment-error': { readonly reason: string } + 'queue-item-not-found': { readonly itemId: MessageId } + 'steer-unavailable': { readonly itemId: MessageId } + 'title-invalid': { readonly sessionId: SessionId } + 'fork-unavailable': { readonly sessionId: SessionId } + 'subagent-not-found': { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + } + 'subagent-catalog-diagnostic': { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } + 'subagent-unauthorized': { readonly childSessionId: SessionId } + internal: Record +} + +/** Session business failure returned without throwing a carrier error. */ +export type SessionError = { + [Code in keyof SessionErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: SessionErrorDetailsMap[Code] + } +}[keyof SessionErrorDetailsMap] + +/** Session list request. */ +export interface SessionListRequest { + readonly cursor?: string +} + +/** Session list response value. */ +export interface SessionListValue { + readonly items: readonly SessionSummary[] +} + +/** Session search request. */ +export interface SessionSearchRequest { + readonly query: string +} + +/** Session search response value. */ +export interface SessionSearchValue { + readonly items: readonly SessionSearchItem[] + readonly hasMore: boolean +} + +/** Session creation or explicit-id adoption request. */ +export interface SessionCreateRequest { + readonly workspaceId?: WorkspaceId + readonly cwd?: string + readonly sessionId?: SessionId + readonly agentPreset?: string +} + +/** Session creation response value. */ +export interface SessionCreateValue { + readonly sessionId: SessionId + readonly agentPreset?: string +} + +/** Model-directory request. */ +export interface SessionModelsRequest { + readonly sessionId: SessionId +} + +/** Session model-selection request. */ +export interface SessionSelectModelRequest extends ModelSelection { + readonly sessionId: SessionId +} + +/** Accepted model selection after Host resolution. */ +export interface SessionSelectModelValue { + readonly selected: ModelSelection +} + +/** Session rename request. */ +export interface SessionRenameRequest { + readonly sessionId: SessionId + readonly title: string +} + +/** Normalized title and the durable event position that committed it. */ +export interface SessionRenameValue { + readonly title: string + readonly seq: number +} + +/** Session fork request. */ +export interface SessionForkRequest { + readonly sessionId: SessionId + readonly atSeq?: number +} + +/** Identity of a newly forked Session. */ +export interface SessionForkValue { + readonly sessionId: SessionId +} + +/** Session prompt request. */ +export interface SessionPromptRequest { + /** Client-minted identity persisted on the exact accepted user message. */ + readonly requestId: SessionRequestId + readonly sessionId: SessionId + readonly mode: 'queue' | 'steer' + readonly content: readonly PromptContentPart[] + readonly clientTimeZone?: string +} + +/** Receipt after one prompt enters the target Agent inbox. */ +export interface SessionPromptValue { + readonly accepted: true +} + +/** Durable image read request. */ +export interface SessionAttachmentRequest { + readonly sessionId: SessionId + readonly attachmentId: AttachmentIdType +} + +/** Durable image read response value. */ +export interface SessionAttachmentValue { + readonly attachment: ImageAttachmentRef + readonly data: string +} + +/** Pending queue mutation request. */ +export interface SessionUpdateQueueRequest { + readonly sessionId: SessionId + readonly itemId: MessageId + readonly action: QueueAction +} + +/** Receipt after one pending queue mutation commits. */ +export interface SessionUpdateQueueValue { + readonly accepted: true +} + +/** Active-turn cancellation request. */ +export interface SessionCancelRequest { + readonly sessionId: SessionId +} + +/** Receipt after cancellation is admitted to the live Agent. */ +export interface SessionCancelValue { + readonly accepted: true +} + +/** Client-minted prompt identity used to reconcile optimistic and durable messages. */ +export type SessionRequestId = Branded<'session-request-id'> + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + /** Browser prompt correlation and optional Host-validated time zone. */ + 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string } + } +} + +/** Durable identity selecting an ordinary Session or one direct subagent child. */ +export type SessionAddress = + | { readonly kind: 'session'; readonly sessionId: SessionId } + | { + readonly kind: 'subagent' + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + readonly mode: 'one-shot' | 'continuable' + } + +/** JSON-safe call render intent crossing the Session Remote boundary. */ +export type SessionToolCallView = + | (Omit & { readonly rawInput?: JsonValue }) + | TerminalCallView + | DiffCallView + +/** Host-computed render intent accompanying one tool event. */ +export type SessionToolView = + | { readonly for: 'call'; readonly view: SessionToolCallView } + | { readonly for: 'result'; readonly view: ToolResultView } + +/** One raw Session event plus its optional transient render intent. */ +export interface SessionEventEntry { + readonly event: SessionWireEvent + readonly view?: SessionToolView +} + +/** Session event wire form; durable readers own recognition of merge-extensible event names. */ +export interface SessionWireEvent { + readonly type: string + readonly seq: number + readonly time: number + readonly data: JsonValue + readonly ignorable?: true + readonly sourceEventSeqs?: number[] + readonly surfaceOp?: SurfaceOp +} + +/** One message-aligned backwards-history request. */ +export interface SessionPageRequest { + readonly address: SessionAddress + /** Inclusive log cut obtained from the corresponding follow opening frame. */ + readonly throughSeq: number + readonly beforeSeq?: number + readonly maxMessages?: number +} + +/** One live event request, optionally resuming after an already-applied event. */ +export interface SessionFollowRequest { + readonly address: SessionAddress + readonly afterSeq?: number +} + +/** One contiguous backwards page of a Session log. */ +export interface SessionPage { + readonly events: readonly SessionEventEntry[] + readonly hasMore: boolean + readonly projections?: SessionProjectionsBlock +} + +/** Initial cursor followed by ordered events appended after that cursor. */ +export type SessionFollowFrame = + | { readonly type: 'opened'; readonly cursor: number } + | ({ readonly type: 'event' } & SessionEventEntry) + +/** One pending inbox occurrence in the authoritative queue snapshot. */ +export interface SessionQueuedItem { + readonly id: MessageId + readonly placement: 'queued' | 'steering' | 'context' + /** JSON-safe message fields consumed by pending-queue presentation. */ + readonly message: { + readonly id: MessageId + readonly content: readonly JsonValue[] + } +} + +/** Browser-safe background-job row. */ +export interface SessionJob { + readonly id: JobId + readonly kind: string + readonly label: string + readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + readonly detail?: string + readonly startedAt: number + readonly finishedAt?: number +} + +/** Complete live control baseline emitted once per control stream generation. */ +export interface SessionControlBaseline { + readonly queues: Readonly> + readonly jobs: Readonly> + readonly projections: Readonly> +} + +/** One finished projection value and its durable watermark. */ +export interface SessionProjectionUpdate { + readonly sessionId: SessionId + readonly key: string + readonly value: JsonValue + readonly seq: number +} + +/** Host-wide live state stream. Each generation starts with exactly one baseline. */ +export type SessionControlFrame = + | { readonly type: 'baseline'; readonly value: SessionControlBaseline } + | { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] } + | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } + | ({ readonly type: 'projection' } & SessionProjectionUpdate) + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * A Session became visible to Session list consumers. + * @mode emit + * @param summary - initial list row for the Session. + */ + 'api-session/added'(summary: SessionSummary): void + /** + * A Session left the live Host registry. + * @mode emit + * @param sessionId - removed Session identity. + */ + 'api-session/removed'(sessionId: SessionId): void + /** + * One Agent changed running state. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param running - whether the Agent is running. + */ + 'api-session/status'(sessionId: SessionId, running: boolean): void + /** + * One user-authored durable message advanced Session list activity. + * @mode emit + * @param sessionId - addressed Session identity. + * @param updatedAt - durable message time used for list ordering. + */ + 'api-session/activity'(sessionId: SessionId, updatedAt: number): void + /** + * One Agent failed outside a durable turn position. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param message - user-safe failure chain. + */ + 'api-session/error'(sessionId: SessionId, message: string): void + } +} + +/** JSON-compatible projection value accepted by list consumers. */ +export type SessionProjectionValue = JsonValue diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts new file mode 100644 index 0000000000..7256f6b5fe --- /dev/null +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -0,0 +1,319 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ApiSessionAgentController, + ApiSessionCwdConflict, + ApiSessionNotFound, + ApiSessionSubagentOwnership, + inspectApiSession, +} from '../src/agent.ts' + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + ctx.provide('agentDefaultModel', { + currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + saveSelection: () => Promise.resolve(), + } as never) + return { ctx, agents: new ApiSessionAgentController(ctx) } +} + +function header(id: string, cwd: string | null = '/workspace'): SessionHeader { + return { + version: 0, + id: SessionId(id), + createdAt: 1, + ...(cwd === null ? {} : { cwd }), + } +} + +function agent(ctx: Context, meta: SessionHeader): Agent { + const session = ctx.sessions.create(meta.id, { meta }) + return { id: meta.id, session, status: 'idle', ctx } as Agent +} + +function unpublishedAgent(ctx: Context, meta: SessionHeader): Agent { + return { + id: meta.id, + session: { id: meta.id, header: meta, events: [] }, + status: 'idle', + ctx, + } as unknown as Agent +} + +describe('ApiSession identity failures', () => { + it('describes cwd conflicts with and without a recorded cwd', () => { + expect(new ApiSessionCwdConflict(SessionId('missing-cwd'), '/wanted', undefined).message) + .toContain('records no cwd') + expect(new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/wanted', '/existing').message) + .toContain('belongs to "/existing"') + }) + + it('rejects absent persistence, catalog misses, and cwd-less inspected artifacts', async () => { + const ctx = new Context() + roots.push(ctx) + await expect(inspectApiSession(ctx, SessionId('missing'))) + .rejects.toThrow('session persistence is not configured') + + const inspect = vi.fn(() => Promise.resolve({ meta: header('missing'), events: [] as SessionEvent[] })) + const disposeMissing = ctx.provide('sessionPersistence', { + list: () => Promise.resolve([]), + inspect, + } as never) + await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound) + expect(inspect).not.toHaveBeenCalled() + disposeMissing() + + const listed = header('cwd-less-catalog', null) + const disposeListed = ctx.provide('sessionPersistence', { + list: () => Promise.resolve([listed]), + inspect, + } as never) + await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound) + disposeListed() + + const catalog = header('cwd-less-inspect') + const inspected = header('cwd-less-inspect', null) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([catalog]), + inspect: () => Promise.resolve({ meta: inspected, events: [] }), + } as never) + await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound) + }) +}) + +describe('ApiSession Agent lookup and recovery', () => { + it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => { + const { ctx } = await harness() + const live = agent(ctx, header('live')) + ctx.agents.register(live) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([]), + inspect: vi.fn(), + } as never) + const host = ctx.typert.contexts.getHost('agent') + if (host === undefined) throw new Error('Agent Context resolver was not registered') + + await expect(host.resolve(live.id)).resolves.toBe(live.ctx) + await expect(host.resolve(SessionId('missing'))).rejects.toBeInstanceOf(TypertLookupFailure) + }) + + it('returns raced ordinary Agents and ownership failures after resume throws', async () => { + const ordinary = await harness() + const ordinaryMeta = header('ordinary-race') + ordinary.ctx.provide('sessionPersistence', { + list: () => Promise.resolve([ordinaryMeta]), + inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }), + } as never) + const winner = agent(ordinary.ctx, ordinaryMeta) + vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => { + ordinary.ctx.agents.register(winner) + throw new Error('raced publication') + }) + await expect(ordinary.agents.resolveAgent(ordinaryMeta.id)).resolves.toEqual({ agent: winner }) + + const child = await harness() + const childMeta = header('child-race') + child.ctx.provide('sessionPersistence', { + list: () => Promise.resolve([childMeta]), + inspect: () => Promise.resolve({ meta: childMeta, events: [] }), + } as never) + vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => { + child.ctx.sessions.create(childMeta.id, { + meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + throw new Error('raced child publication') + }) + await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ + error: { code: 'agent-busy' }, + }) + }) + + it('reports not-found and ordinary resume failures without fabricating an Agent', async () => { + const missing = await harness() + missing.ctx.provide('sessionPersistence', { + list: () => Promise.resolve([]), + inspect: vi.fn(), + } as never) + await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({ + error: { code: 'session-not-found' }, + }) + + const failed = await harness() + const meta = header('failed') + failed.ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] }), + } as never) + vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable')) + await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({ + error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string }, + }) + }) +}) + +describe('ApiSession create or adoption', () => { + it('shares one in-flight creation between concurrent callers', async () => { + const { ctx, agents } = await harness() + const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-')) + const meta = header('concurrent-create', cwd) + const created = unpublishedAgent(ctx, meta) + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => { + await gate + return { agent: created, dispose: () => Promise.resolve() } + }) + + const first = agents.ensureSession(meta.id, cwd, false) + const second = agents.ensureSession(meta.id, cwd, false) + release() + + await expect(Promise.all([first, second])).resolves.toEqual([created, created]) + expect(create).toHaveBeenCalledOnce() + }) + + it('accepts a raced ordinary creation and rejects a raced attached child', async () => { + const ordinary = await harness() + const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-')) + const ordinaryMeta = header('create-race', cwd) + const winner = agent(ordinary.ctx, ordinaryMeta) + vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => { + ordinary.ctx.agents.register(winner) + throw new Error('raced creation') + }) + await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false)) + .resolves.toBe(winner) + + const child = await harness() + const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-')) + const childId = SessionId('create-child-race') + vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => { + child.ctx.sessions.create(childId, { + meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + throw new Error('raced child creation') + }) + await expect(child.agents.ensureSession(childId, childCwd, false)) + .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) + }) + + it('validates ownership and cwd on the Agent returned by creation', async () => { + const child = await harness() + const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-')) + const childMeta = { + ...header('returned-child', childCwd), + parentSession: SessionId('parent'), + origin: 'subagent' as const, + } + const childAgent = unpublishedAgent(child.ctx, childMeta) + vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({ + agent: childAgent, + dispose: () => Promise.resolve(), + }) + await expect(child.agents.ensureSession(childMeta.id, childCwd, false)) + .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) + + const wrong = await harness() + const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-')) + const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other')) + vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({ + agent: wrongAgent, + dispose: () => Promise.resolve(), + }) + await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false)) + .rejects.toBeInstanceOf(ApiSessionCwdConflict) + }) + + it('resumes a matching persisted identity and preserves its selected preset', async () => { + const { ctx, agents } = await harness() + const meta = { ...header('stored'), agentPreset: 'minimal' } + const events = [{ + type: 'agent-preset/selected', + seq: 0, + time: 1, + data: { agentPreset: 'minimal' }, + }] as SessionEvent[] + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + } as never) + ctx.provide('agentPresets', { + resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }), + mount: () => Promise.resolve(), + } as never) + const resumed = { + id: meta.id, + session: { id: meta.id, header: meta, events }, + status: 'idle', + ctx, + } as unknown as Agent + const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({ + agent: resumed, + dispose: () => Promise.resolve(), + }) + + await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed) + expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id })) + }) + + it('rejects an ownership race before resume and a persisted cwd conflict', async () => { + const child = await harness() + const childMeta = header('resume-child-race') + child.ctx.provide('sessionPersistence', { + list: () => Promise.resolve([childMeta]), + inspect: () => Promise.resolve({ meta: childMeta, events: [] }), + } as never) + child.ctx.provide('agentPresets', { + resolve: () => { + child.ctx.sessions.create(childMeta.id, { + meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + return Promise.resolve({ id: 'standard' }) + }, + mount: () => Promise.resolve(), + } as never) + await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ + error: { code: 'agent-busy' }, + }) + + const conflict = await harness() + const stored = header('stored-cwd-conflict', '/stored') + conflict.ctx.provide('sessionPersistence', { + list: () => Promise.resolve([stored]), + inspect: () => Promise.resolve({ meta: stored, events: [] }), + } as never) + await expect(conflict.agents.ensureSession(stored.id, '/requested', true)) + .rejects.toBeInstanceOf(ApiSessionCwdConflict) + }) + + it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => { + const { agents } = await harness() + const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-')) + const file = join(parent, 'file') + writeFileSync(file, 'not a directory') + await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) + .rejects.toThrow('failed to ensure project directory') + + const composition = await agents.composeAgent(undefined) + expect(() => composition.setup(new Context())).toThrow('Agent setup has no scoped Agent') + }) +}) diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts new file mode 100644 index 0000000000..04508378f9 --- /dev/null +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -0,0 +1,226 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' +import type { + ConnectionHandle, + HostDescription, +} from '@deepseek-ai/dsh-client-connection/client' +import { + RemoteStreamCarrierError, + RemoteStream, + type RemoteStreamOptions, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as SessionClient from '../src/client/index.ts' +import { ClientSessions } from '../src/client/sessions/service.ts' +import { FakeApiClient, fakeRemote } from './fake-api.client.ts' + +const DESCRIPTION: HostDescription = { + version: 'fixture', + cwd: '/fixture', + attachedSessions: 0, + home: '/home/fixture', + canOpenPath: true, +} + +const sid = (value: string): SessionId => value as SessionId + +type RemoteListener = (...args: never[]) => void + +interface Bench { + readonly ctx: Context + readonly api: FakeApiClient + readonly fiber: Fiber + readonly sessions: ClientSessions + dispatch(event: string, ...args: unknown[]): void + publishHost(description: HostDescription | undefined): void +} + +const contexts = new Set() + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() })) + contexts.clear() +}) + +async function mount(initialHost?: HostDescription): Promise { + const ctx = new Context() + contexts.add(ctx) + await ctx.plugin(TypertRegistry) + const api = new FakeApiClient() + const remote = fakeRemote(api) + const listeners = new Map>() + const hostListeners = new Set<() => void>() + let host = initialHost + const connection: ConnectionHandle = { + api, + isLoopback: true, + hostDescription: { + getSnapshot: () => host, + subscribe: (listener) => { + hostListeners.add(listener) + return () => { hostListeners.delete(listener) } + }, + }, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, + registerGenerationSource: () => () => {}, + start: () => ({ stop: () => {} }), + } + ctx.reflect.provide('connection', connection) + ctx.reflect.provide('remote', { + ...remote, + $stream: (options: RemoteStreamOptions) => ( + new RemoteStream(connection, options) + ), + $on: (event: string, listener: RemoteListener) => { + const eventListeners = listeners.get(event) ?? new Set() + eventListeners.add(listener) + listeners.set(event, eventListeners) + return () => { eventListeners.delete(listener) } + }, + }) + ctx.reflect.provide('remote.commands', remote.commands) + ctx.reflect.provide('remote.session', remote.session) + const fiber = ctx.plugin(SessionClient) + await fiber + const sessions = SessionClient.resolveClientSessions(ctx) as ClientSessions + return { + ctx, + api, + fiber, + sessions, + dispatch: (event, ...args) => { + for (const listener of listeners.get(event) ?? []) listener(...args as never[]) + }, + publishHost: (description) => { + host = description + for (const listener of [...hostListeners]) listener() + }, + } +} + +async function flush(): Promise { + for (let index = 0; index < 12; index++) await Promise.resolve() +} + +describe('Session Controller Client apply', () => { + it('requires the installed Session service at the resolver boundary', () => { + const ctx = new Context() + contexts.add(ctx) + + expect(() => SessionClient.resolveClientSessions(ctx)) + .toThrow('session-controller: Client sessions service unavailable') + }) + + it('routes Session Remote Events and connection generations into the object layer', async () => { + const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') + const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') + const bench = await mount() + expect(connected).not.toHaveBeenCalled() + + bench.dispatch('api-session/added', { + sessionId: sid('session-1'), + updatedAt: 1, + running: false, + blank: true, + }) + await flush() + expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ + running: false, + updatedAt: 1, + }) + + bench.dispatch('api-session/status', sid('session-1'), true) + bench.dispatch('api-session/activity', sid('session-1'), 9) + bench.dispatch('api-session/error', sid('session-1'), 'agent failed') + await flush() + expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ + running: true, + updatedAt: 9, + }) + expect(error).toHaveBeenCalledWith(sid('session-1'), 'agent failed') + + bench.dispatch('api-session/removed', sid('session-1')) + await flush() + expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined() + + bench.ctx.emit('connection/reset') + expect(connected).toHaveBeenCalledOnce() + }) + + it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => { + const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + const bench = await mount(DESCRIPTION) + await flush() + + expect(accept).toHaveBeenCalledWith({ + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, + }) + + bench.api.failStreams(new RemoteStreamCarrierError('generation lost')) + await flush() + expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) + + bench.api.pushControl({ type: 'baseline', value: bench.api.controlBaseline } as never) + await vi.waitFor(() => { + expect(logged).toHaveBeenCalledWith( + '[session-controller] control stream failed:', + expect.objectContaining({ message: 'session control stream emitted more than one opening snapshot' }), + ) + }) + }) + + it('materializes Host-addressed Agent scopes before the Session list arrives', async () => { + const bench = await mount() + const adapter = bench.ctx.typert.contexts.getClient('agent') + const first = adapter?.resolve(sid('agent-early')) + + expect(first).toBeDefined() + expect(bench.sessions.scopeOf(first as Context)).toBe(sid('agent-early')) + expect(adapter?.resolve(sid('agent-early'))).toBe(first) + }) + + it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => { + const bench = await mount(DESCRIPTION) + await flush() + expect(bench.sessions.list.getSnapshot().phase).toBe('ready') + + bench.dispatch('api-session/added', { + sessionId: sid('agent-1'), + updatedAt: 1, + running: false, + blank: true, + }) + await flush() + const scoped = bench.sessions.scope(sid('agent-1')) + const adapter = bench.ctx.typert.contexts.getClient('agent') + expect(scoped).toBeDefined() + expect(adapter?.identity(bench.ctx)).toBeUndefined() + expect(adapter?.identity(scoped!)).toBe(sid('agent-1')) + expect(adapter?.resolve(sid('agent-1'))).toBe(scoped) + + await bench.fiber.dispose() + expect(bench.ctx.typert.contexts.getClient('agent')).toBeUndefined() + }) + + it('waits for a Host generation before retrying the control stream', async () => { + const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') + const bench = await mount() + await flush() + expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) + + bench.api.failStreams(new RemoteStreamCarrierError('offline')) + await flush() + expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) + + bench.publishHost(DESCRIPTION) + await flush() + expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) + }) +}) diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts new file mode 100644 index 0000000000..9b3a7fa015 --- /dev/null +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -0,0 +1,88 @@ +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' +import { describe, expect, it, vi } from 'vitest' +import { MutableSessionEventSource } from '../src/client/contract/events.ts' +import { transportResult } from '../src/client/contract/result.ts' + +function entry(seq: number): SessionEventEntry { + return { + event: { + type: 'fixture/event', + seq, + time: seq, + data: { seq }, + ignorable: true, + }, + } +} + +describe('Client Session contracts', () => { + it('publishes exact replace, prepend, and append event-window changes', () => { + const feed = new MutableSessionEventSource() + const listener = vi.fn() + const dispose = feed.subscribe(listener) + const first = entry(1) + const older = entry(0) + const live = entry(2) + + feed.replace([first], true) + expect(feed.getSnapshot()).toEqual({ + entries: [first], + hasMore: true, + revision: 1, + change: { kind: 'replace', entries: [first] }, + }) + + feed.prepend([older], false) + expect(feed.getSnapshot()).toEqual({ + entries: [older, first], + hasMore: false, + revision: 2, + change: { kind: 'prepend', entries: [older] }, + }) + + feed.append(live) + expect(feed.getSnapshot()).toEqual({ + entries: [older, first, live], + hasMore: false, + revision: 3, + change: { kind: 'append', entries: [live] }, + }) + expect(listener).toHaveBeenCalledTimes(3) + + dispose() + feed.append(entry(3)) + expect(listener).toHaveBeenCalledTimes(3) + }) + + it('does not traverse the complete event window while appending', () => { + const feed = new MutableSessionEventSource() + const first = entry(1) + const base = [first] + const iterate = vi.fn(Array.prototype[Symbol.iterator].bind(base)) + Object.defineProperty(base, Symbol.iterator, { value: iterate }) + feed.replace(base, false) + iterate.mockClear() + + const before = feed.getSnapshot() + const live = entry(2) + feed.append(live) + const after = feed.getSnapshot() + + expect(iterate).not.toHaveBeenCalled() + expect(before.entries).toEqual([first]) + expect(after.entries).toEqual([first, live]) + expect(after.entries).toBe(after.entries) + expect(iterate).toHaveBeenCalledOnce() + }) + + it('folds Error and non-Error carrier rejections into Client failures', () => { + expect(transportResult(new Error('transport unavailable'))).toEqual({ + ok: false, + error: { code: 'internal', message: 'transport unavailable', details: {} }, + }) + expect(transportResult(404)).toEqual({ + ok: false, + error: { code: 'internal', message: '404', details: {} }, + }) + }) +}) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts new file mode 100644 index 0000000000..b702c60a2c --- /dev/null +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -0,0 +1,267 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import { PresetMountError } from '@deepseek-ai/dsh-agent-presets' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace' +import { describe, expect, it, vi } from 'vitest' +import { + ApiSessionAgentController, + ApiSessionCwdConflict, +} from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' + +async function expectFailure(operation: Promise, code: string): Promise { + await expect(operation).rejects.toMatchObject({ failure: { code } }) +} + +function controllerAgents(overrides: object = {}): ApiSessionAgentController { + return { + ensureSession: () => Promise.resolve(), + composeAgent: () => Promise.resolve({ setup: () => {} }), + ...overrides, + } as unknown as ApiSessionAgentController +} + +async function baseContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + ctx.provide('agentDefaultModel', { + currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + saveSelection: () => Promise.resolve(), + } as never) + return ctx +} + +describe('Session creation failures', () => { + it('mints an identity with the default cwd when no explicit target is supplied', async () => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const ensureSession = vi.fn((sessionId: SessionId, cwd: string) => { + const session = ctx.sessions.create(sessionId, { meta: { cwd } }) + return Promise.resolve({ id: sessionId, session } as Agent) + }) + const controller = new SessionCommandController( + ctx, + controllerAgents({ ensureSession }), + '/default-workspace', + ) + + const created = await controller.create({}) + + expect(created.sessionId).toMatch(/^session-/) + expect(created).not.toHaveProperty('agentPreset') + expect(ensureSession).toHaveBeenCalledWith( + created.sessionId, + '/default-workspace', + false, + undefined, + ) + await ctx.fiber.dispose() + }) + + it('maps missing Workspaces and attachment failures', async () => { + const missing = await baseContext() + missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const missingController = new SessionCommandController( + missing, + controllerAgents(), + '/default', + ) + await expectFailure(missingController.create({ + workspaceId: 'missing' as WorkspaceId, + }), 'workspace-not-found') + await missing.fiber.dispose() + + const failed = await baseContext() + const workspace = { + id: 'workspace-1' as WorkspaceId, + path: '/workspace', + attachSession: () => Promise.reject(new Error('read-only workspace')), + } as unknown as Workspace + failed.provide('workspaceRegistry', { + get: () => workspace, + list: () => [workspace], + } as never) + const failedController = new SessionCommandController( + failed, + controllerAgents(), + '/default', + ) + await expectFailure(failedController.create({ + sessionId: SessionId('workspace-session'), + workspaceId: workspace.id, + }), 'workspace-attach-failed') + await failed.fiber.dispose() + }) + + it.each([ + { + error: new PresetMountError('broken', 'invalid composition'), + code: 'agent-preset-invalid', + }, + { + error: new ApiSessionCwdConflict(SessionId('cwd-less'), '/requested', undefined), + code: 'session-conflict', + }, + { + error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'), + code: 'session-conflict', + }, + { + error: new Error('factory unavailable'), + code: 'internal', + }, + ])('maps $code creation failures', async ({ error, code }) => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const controller = new SessionCommandController( + ctx, + controllerAgents({ ensureSession: () => Promise.reject(error) }), + '/default', + ) + + await expectFailure(controller.create({ + sessionId: SessionId('failed-create'), cwd: '/requested', + }), code) + await ctx.fiber.dispose() + }) + + it('rejects contradictory create targets', async () => { + const ctx = await baseContext() + const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + + await expectFailure(controller.create({ + workspaceId: 'workspace-1' as WorkspaceId, + cwd: '/workspace', + }), 'bad-request') + await ctx.fiber.dispose() + }) + +}) + +function completedSession( + ctx: Context, + id: string, + cwd?: string, + lineage: { parentSession?: SessionId; origin?: 'subagent' } = {}, +) { + const session = ctx.sessions.create(SessionId(id), { + meta: { ...(cwd === undefined ? {} : { cwd }), ...lineage }, + }) + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'work' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return session +} + +function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle { + return { + agent: { id: sessionId, status: 'idle', ctx } as Agent, + dispose: () => Promise.resolve(), + } +} + +describe('Session fork failures', () => { + it('distinguishes missing cold sources from unavailable persistence', async () => { + const unavailable = await baseContext() + unavailable.provide('workspaceRegistry', { list: () => [] } as never) + const unavailableController = new SessionCommandController( + unavailable, controllerAgents(), '/default', + ) + await expectFailure(unavailableController.fork({ + sessionId: SessionId('missing'), + }), 'internal') + await unavailable.fiber.dispose() + + const missing = await baseContext() + missing.provide('workspaceRegistry', { list: () => [] } as never) + missing.provide('sessionPersistence', { + list: () => Promise.resolve([]), + inspect: vi.fn(), + } as never) + const missingController = new SessionCommandController(missing, controllerAgents(), '/default') + await expectFailure(missingController.fork({ + sessionId: SessionId('missing'), + }), 'session-not-found') + await missing.fiber.dispose() + }) + + it('rejects a Session with no completed turn', async () => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { list: () => [] } as never) + const source = ctx.sessions.create(SessionId('empty-source')) + const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + + await expectFailure(controller.fork({ sessionId: source.id }), 'fork-unavailable') + await ctx.fiber.dispose() + }) + + it('maps lineage lookup and Agent creation failures', async () => { + const lineage = await baseContext() + lineage.provide('workspaceRegistry', { list: () => [] } as never) + lineage.provide('sessionQuery', { + traceSession: () => Promise.reject(new Error('lineage unavailable')), + } as never) + const child = completedSession(lineage, 'subagent-source', '/workspace', { + parentSession: SessionId('parent'), + origin: 'subagent', + }) + const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default') + await expectFailure(lineageController.fork({ sessionId: child.id }), 'internal') + await lineage.fiber.dispose() + + const creation = await baseContext() + creation.provide('workspaceRegistry', { list: () => [] } as never) + const source = completedSession(creation, 'creation-source', '/workspace') + vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed')) + const creationController = new SessionCommandController(creation, controllerAgents(), '/default') + await expectFailure(creationController.fork({ sessionId: source.id }), 'internal') + await creation.fiber.dispose() + }) + + it('omits absent cwd and preset metadata before reporting Workspace attachment failure', async () => { + const ctx = await baseContext() + const source = completedSession(ctx, 'workspace-source') + const workspace = { + id: 'workspace-1' as WorkspaceId, + sessionIds: [source.id], + attachSession: () => Promise.reject(new Error('workspace write failed')), + } as unknown as Workspace + ctx.provide('workspaceRegistry', { list: () => [workspace] } as never) + const create = vi.spyOn(ctx.agents, 'create').mockImplementation( + (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), + ) + const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + + await expectFailure(controller.fork({ sessionId: source.id }), 'workspace-attach-failed') + const options = create.mock.calls[0]?.[0] + if (options === undefined) throw new Error('Agent creation was not attempted') + expect(options.meta).not.toHaveProperty('cwd') + expect(options.meta).not.toHaveProperty('agentPreset') + await ctx.fiber.dispose() + }) + + it('carries the composed Agent preset into the child metadata', async () => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { list: () => [] } as never) + const source = completedSession(ctx, 'preset-source', '/workspace') + const create = vi.spyOn(ctx.agents, 'create').mockImplementation( + (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), + ) + const controller = new SessionCommandController(ctx, controllerAgents({ + composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }), + }), '/default') + + const forked = await controller.fork({ sessionId: source.id }) + expect(forked.sessionId).toMatch(/^session-/) + const options = create.mock.calls[0]?.[0] + if (options === undefined) throw new Error('Agent creation was not attempted') + expect(options.meta?.agentPreset).toBe('minimal') + await ctx.fiber.dispose() + }) +}) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts new file mode 100644 index 0000000000..cb71c8f009 --- /dev/null +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -0,0 +1,226 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { describe, expect, it, vi } from 'vitest' +import { ApiSessionAgentController } from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' + +async function commandHarness(): Promise<{ + ctx: Context + controller: SessionCommandController + agent: Agent + inbox: Inbox + steer: ReturnType + cancel: ReturnType +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(SessionId('commands-session'), { meta: { cwd: '/workspace' } }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const steer = vi.fn() + const cancel = vi.fn() + const agent = { + id: session.id, + session, + inbox, + status: 'running', + ctx, + steer, + followup: vi.fn(), + cancel, + } as unknown as Agent + ctx.agents.register(agent) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + ctx.provide('agentDefaultModel', { + currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + saveSelection: () => Promise.resolve(), + } as never) + const selection: ModelSelectionRef = { + current: { provider: 'fixture', model: 'fixture-model' }, + assembled: undefined, + } + const agents = { + resolveAgent: () => Promise.resolve({ agent }), + selectionFor: () => selection, + serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), + composeAgent: () => Promise.resolve({ setup: () => {} }), + } as unknown as ApiSessionAgentController + return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), agent, inbox, steer, cancel } +} + +async function expectFailure(operation: Promise, code: string): Promise { + await expect(operation).rejects.toMatchObject({ failure: { code } }) +} + +describe('Session queue commands', () => { + it('edits, removes, steers, and rejects stale queue occurrences', async () => { + const { ctx, controller, agent, inbox, steer, cancel } = await commandHarness() + const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) + const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) + inbox.append('next-turn', queued) + inbox.append('next-step', nextStep) + + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: agent.id, + itemId: queued.id, + action: { + kind: 'edit', + content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId('att-edit'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }], + }, + })), 'attachment-error') + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' }, + })), 'queue-item-not-found') + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' }, + })), 'queue-item-not-found') + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' }, + })), 'steer-unavailable') + + Object.assign(agent, { status: 'idle' }) + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' }, + })), 'steer-unavailable') + expect(controller.updateQueue({ + sessionId: agent.id, + itemId: queued.id, + action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, + })).toEqual({ accepted: true }) + expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }]) + expect(controller.updateQueue({ + sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' }, + })).toEqual({ accepted: true }) + + Object.assign(agent, { status: 'running' }) + const steered = inbox.nextTurn[0] + if (steered === undefined) throw new Error('missing edited queue item') + expect(controller.updateQueue({ + sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' }, + })).toEqual({ accepted: true }) + expect(steer).toHaveBeenCalledWith(steered) + + await expectFailure(Promise.resolve().then(() => controller.cancel({ + sessionId: SessionId('missing'), + })), 'session-not-found') + expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true }) + expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + await ctx.fiber.dispose() + }) +}) + +function imageRef(id: string): ImageAttachmentRef { + return { + attachmentId: AttachmentId(id), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } +} + +function event(type: string, seq: number, data: unknown): SessionEvent { + return { type, seq, time: seq + 1, data } as SessionEvent +} + +async function persistedController( + events: SessionEvent[], + readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>, +): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('cold-attachment') + const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + } as never) + ctx.provide('attachments', { readImage } as never) + const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController + return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId } +} + +describe('Session attachment authorization', () => { + it('finds references in direct, message, inserted, nested, and streamed content', async () => { + const nested = imageRef('nested') + const message = imageRef('message') + const inserted = imageRef('inserted') + const streamed = imageRef('streamed') + const events = [ + event('fixture/direct', 0, { + content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, { + type: 'tool-result', content: [{ type: 'image', attachment: nested }], + }], + }), + event('assistant/message', 1, { message: { content: [{ type: 'image', attachment: message }] } }), + event('agent/inbox/spliced', 2, { inserted: [{ content: [{ type: 'image', attachment: inserted }] }] }), + event('assistant/chunk', 3, { + chunk: { type: 'block-end', block: { type: 'image', attachment: streamed } }, + }), + ] + const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) })) + const { ctx, controller, sessionId } = await persistedController(events, readImage) + + for (const ref of [nested, message, inserted, streamed]) { + await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId })) + .resolves.toEqual({ attachment: ref, data: 'AQ==' }) + } + expect(readImage).toHaveBeenCalledTimes(4) + await ctx.fiber.dispose() + }) + + it('maps missing persistence identities and attachment backend failures', async () => { + const noPersistence = new Context() + await noPersistence.plugin(SessionStore) + const noPersistenceController = new SessionCommandController( + noPersistence, + { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, + '/workspace', + ) + await expectFailure(noPersistenceController.attachment({ + sessionId: SessionId('missing'), attachmentId: AttachmentId('att'), + }), 'internal') + + const missing = new Context() + await missing.plugin(SessionStore) + missing.provide('sessionPersistence', { + list: () => Promise.resolve([]), + inspect: vi.fn(), + } as never) + const missingController = new SessionCommandController( + missing, + { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, + '/workspace', + ) + await expectFailure(missingController.attachment({ + sessionId: SessionId('missing'), attachmentId: 'att' as never, + }), 'session-not-found') + + for (const thrown of [ + new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'), + new Error('backend offline'), + ]) { + const ref = imageRef(`failure-${thrown.name}`) + const fixture = await persistedController( + [event('fixture/content', 0, { content: [{ type: 'image', attachment: ref }] })], + () => Promise.reject(thrown), + ) + await expectFailure(fixture.controller.attachment({ + sessionId: fixture.sessionId, + attachmentId: ref.attachmentId, + }), thrown instanceof AttachmentError ? 'attachment-error' : 'internal') + await fixture.ctx.fiber.dispose() + } + }) +}) diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts new file mode 100644 index 0000000000..0ad49b586c --- /dev/null +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -0,0 +1,215 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JobOutcome } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import { describe, expect, it } from 'vitest' +import { SessionControlController } from '../src/control.ts' +import type { SessionControlFrame } from '../src/types.ts' + +type BaselineFrame = Extract +type JobFrame = Extract + +function producer(label = 'sleep 60') { + let settle!: (outcome: JobOutcome) => void + const reads = { count: 0 } + const spec = { + kind: 'bash' as const, + label, + run: () => ({ + cancel: () => {}, + done: new Promise((resolve) => { settle = resolve }), + readOutput: () => { reads.count += 1; return 'stolen output' }, + }), + } + return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } } +} + +async function harness(withRegistry: boolean): Promise<{ + ctx: Context + session: Session + agent: Agent + control: SessionControlController +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + if (withRegistry) { + await ctx.plugin(LocalJobRegistry) + ctx.jobs.attachController('session-controller-test') + } + const session = ctx.sessions.create() + const agent = { + id: session.id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + } as Agent + ctx.agents.register(agent) + const control = new SessionControlController(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + return { ctx, session, agent, control } +} + +async function baseline(control: SessionControlController): Promise { + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const first = await iterator.next() + abort.abort() + await iterator.next() + if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline') + return first.value +} + +async function collectJobs( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const jobs: JobFrame[] = [] + for await (const frame of iterable) { + if (frame.type !== 'jobs') continue + jobs.push(frame) + if (jobs.length >= count) abort.abort() + } + return jobs +} + +describe('Session control jobs baseline', () => { + it('represents an attached session with no jobs as an empty set', async () => { + const { session, control } = await harness(true) + const frame = await baseline(control) + expect(frame.value.jobs[session.id]).toEqual([]) + }) + + it('carries the visible set when the stream opens', async () => { + const { ctx, session, agent, control } = await harness(true) + ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent }) + const frame = await baseline(control) + const jobs = frame.value.jobs[session.id] + expect(jobs).toHaveLength(1) + const [job] = jobs ?? [] + expect(job?.startedAt).toBeTypeOf('number') + expect({ ...job, startedAt: 0 }).toEqual({ + id: 'bash-1', + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: 0, + }) + }) +}) + +describe('Session control jobs updates', () => { + it('pushes the owner whole set on registration, stopping, and settlement', async () => { + const { ctx, session, agent, control } = await harness(true) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 3, abort) + + const task = producer() + const id = ctx.jobs.start({ ...task.spec, owner: agent }) + ctx.jobs.kill(id, agent, 'test') + task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + + const frames = await collected + expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) + expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed']) + expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM') + expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number') + }) + + it('drops internal registry fields from the browser view', async () => { + const { ctx, agent, control } = await harness(true) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 1, abort) + ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) + + const [frame] = await collected + expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([ + 'id', + 'kind', + 'label', + 'startedAt', + 'status', + ]) + }) + + it('fans an unowned change out to every attached session', async () => { + const { ctx, control } = await harness(true) + const second = ctx.sessions.create() + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 2, abort) + + ctx.jobs.start(producer('open to every caller').spec) + + const frames = await collected + expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) + expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) + for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller') + }) + + it('does not resume persisted sessions while projecting an unowned change', async () => { + const { ctx, control } = await harness(true) + const coldId = SessionId('session-cold-tasks') + let loaded = false + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + load: () => { loaded = true; throw new Error('job projection must not load a cold log') }, + } as never) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 1, abort) + + ctx.jobs.start(producer().spec) + await collected + expect(loaded).toBe(false) + expect(ctx.agents.get(coldId)).toBeUndefined() + }) + + it('reports empty sets when no jobs registry is composed', async () => { + const { session, control } = await harness(false) + const frame = await baseline(control) + expect(frame.value.jobs[session.id]).toEqual([]) + }) + + it('never consumes model output while projecting a lifecycle', async () => { + const { ctx, agent, control } = await harness(true) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 3, abort) + + const task = producer() + const id = ctx.jobs.start({ ...task.spec, owner: agent }) + ctx.jobs.kill(id, agent, 'test') + task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + await collected + + expect(task.reads.count).toBe(0) + }) + + it('never consumes model output while producing a baseline', async () => { + const { ctx, agent, control } = await harness(true) + const task = producer() + ctx.jobs.start({ ...task.spec, owner: agent }) + + const frame = await baseline(control) + + expect(frame.value.jobs[agent.id]).toHaveLength(1) + expect(task.reads.count).toBe(0) + }) + + it('publishes existing unowned jobs for a session created after stream open', async () => { + const { ctx, control } = await harness(true) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 2, abort) + + ctx.jobs.start(producer('visible to every caller').spec) + const created = ctx.sessions.create() + + const frames = await collected + const forNew = frames.filter(frame => frame.sessionId === created.id) + expect(forNew.at(-1)?.jobs[0]?.label).toBe('visible to every caller') + }) +}) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts new file mode 100644 index 0000000000..9ce0c453a0 --- /dev/null +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -0,0 +1,120 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { describe, expect, it } from 'vitest' +import { SessionControlController } from '../src/control.ts' + +async function harness(): Promise<{ + ctx: Context + control: SessionControlController + agent: Agent + inbox: Inbox +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(SessionId('queue-session')) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const agent = { id: session.id, session, inbox, status: 'running', ctx } as Agent + ctx.agents.register(agent) + return { ctx, control: new SessionControlController(ctx), agent, inbox } +} + +function message(text: string, source: 'user' | 'plugin' = 'user') { + return createUserMessage({ + content: [{ type: 'text', text }], + source: source === 'user' ? { kind: 'user' } : { kind: 'plugin', plugin: 'fixture' }, + }) +} + +describe('Session control queue projection', () => { + it('projects both pending lists in baselines and live replacement frames', async () => { + const { control, inbox } = await harness() + const queued = message('queued') + const steering = message('steering') + const context = message('context', 'plugin') + inbox.append('next-turn', queued) + inbox.append('next-step', steering) + inbox.append('next-step', context) + + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const opened = await iterator.next() + expect(opened.value).toMatchObject({ + type: 'baseline', + value: { + queues: { + 'queue-session': [ + { id: queued.id, placement: 'queued' }, + { id: steering.id, placement: 'steering' }, + { id: context.id, placement: 'context' }, + ], + }, + }, + }) + + const replacement = message('replacement') + inbox.append('next-turn', replacement) + const replaced = await iterator.next() + if (replaced.done || replaced.value.type !== 'queue') throw new Error('missing queue replacement') + expect(replaced.value.items.map(item => item.id)).toContain(replacement.id) + inbox.remove(steering.id) + const removed = await iterator.next() + if (removed.done || removed.value.type !== 'queue') throw new Error('missing queue replacement') + expect(removed.value.items.map(item => item.id)).not.toContain(steering.id) + + abort.abort() + await iterator.next() + }) + + it('ignores inbox events without the exact live Agent session', async () => { + const { ctx, control, agent, inbox } = await harness() + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + await iterator.next() + + const unrelated = ctx.sessions.create(SessionId('unrelated-queue')) + unrelated.append('agent/inbox/spliced', { + target: 'next-turn', + start: 0, + inserted: [message('unrelated')], + }) + const replacement = ctx.sessions.create(SessionId('replacement-session')) + Object.defineProperty(agent, 'session', { configurable: true, value: replacement }) + inbox.append('next-turn', message('wrong-session')) + + abort.abort() + await iterator.next() + }) + + it('drops broadcasts after cancellation has ended its queue', async () => { + const { control, inbox } = await harness() + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + await iterator.next() + const waiting = iterator.next() + await Promise.resolve() + + abort.abort() + inbox.append('next-turn', message('late')) + + await expect(waiting).resolves.toMatchObject({ done: true }) + }) + + it('ends active streams on context disposal after flushing buffered frames', async () => { + const { ctx, control, inbox } = await harness() + const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]() + await iterator.next() + inbox.append('next-turn', message('first')) + inbox.append('next-turn', message('second')) + + const first = await iterator.next() + expect(first).toMatchObject({ done: false, value: { type: 'queue' } }) + await ctx.fiber.dispose() + const second = await iterator.next() + expect(second).toMatchObject({ done: false, value: { type: 'queue' } }) + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + }) +}) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts new file mode 100644 index 0000000000..61b926d93a --- /dev/null +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -0,0 +1,77 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { describe, expect, it, vi } from 'vitest' +import { createSessionTestController } from './test-remote.ts' + +const defaults = { + defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + cwd: '/tmp', +} + +describe('SessionController facade', () => { + it('owns Host service methods and publishes Agent lifecycle projections', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = SessionId('controller-session') + const header: SessionHeader = { + version: 0, + id: sessionId, + createdAt: 1, + cwd: '/workspace', + } + const events: SessionEvent[] = [] + const inspect = vi.fn(() => Promise.resolve({ meta: header, events })) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([header]), + inspect, + } as never) + const controller = createSessionTestController(ctx, defaults) + const status = vi.fn() + const failure = vi.fn() + const activity = vi.fn() + ctx.on('api-session/status', status) + ctx.on('api-session/error', failure) + ctx.on('api-session/activity', activity) + + await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events }) + expect(inspect).toHaveBeenCalledOnce() + + const session = ctx.sessions.create(sessionId, { meta: header }) + const agent = { + id: sessionId, + session, + status: 'idle', + ctx, + } as Agent + ctx.agents.register(agent) + + await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent }) + await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events }) + expect(inspect).toHaveBeenCalledOnce() + ctx.emit('agent/status', { agent, status: 'running' }) + ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'hello' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + expect(status).toHaveBeenCalledWith(sessionId, true) + expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure')) + expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number)) + + const abort = new AbortController() + const iterator = controller.follow({ + address: { kind: 'session', sessionId }, + }, abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'opened', cursor: 0 }, + }) + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/client/runtime/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts similarity index 98% rename from packages/client/runtime/tests/event-script.client.ts rename to packages/api/session-controller/tests/event-script.client.ts index a024af0ab7..0781621296 100644 --- a/packages/client/runtime/tests/event-script.client.ts +++ b/packages/api/session-controller/tests/event-script.client.ts @@ -1,4 +1,6 @@ -import { createUserMessage, createMessage, createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm' +import { + CallId, createMessage, createToolResultMessage, createUserMessage, +} from '@deepseek-ai/dsh-llm' // Minimal SessionEvent builders for orchestration tests (shape mirrors what the // host emits; only the fields the object layer reads). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts new file mode 100644 index 0000000000..6c82ab0352 --- /dev/null +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -0,0 +1,566 @@ +// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo +// data source on a real clock; behavior tests need per-case responses and +// deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. +import type { + IApiClient, + RpcError, RpcResponse, SessionId, SessionSearchItem, SkillEntry, + WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-api-remotes/client' +import type { + SessionAddress, + SessionControlBaseline, + SessionControlFrame, + SessionFollowFrame, + SessionFollowRequest, + SessionModels, + SessionPage, + SessionPageRequest, + SessionSelectModelRequest, + SessionSelectModelValue, +} from '@deepseek-ai/dsh-api-session-controller/types' +import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { + RemoteStream, + type RemoteStreamOptions, +} from '@deepseek-ai/dsh-api-gateway/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' + +const AVAILABLE_STREAM_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + +/** Programmable-default workspace row (branded id, ISO-ish times). */ +function fakeWorkspace(id: string, over: Partial = {}): WorkspaceView { + return { + workspaceId: id as WorkspaceId, + path: '/f/ws', + title: 'ws', + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...over, + } +} + +function addressSessionId(address: SessionAddress): SessionId { + return address.kind === 'session' ? address.sessionId : address.childSessionId +} + +function addressKey(address: SessionAddress): string { + return address.kind === 'session' + ? `session:${address.sessionId}` + : `subagent:${address.parentSessionId}:${address.childSessionId}:${address.mode}` +} + +export interface Deferred { + promise: Promise + resolve(value: T): void + reject(error: unknown): void +} + +/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */ +export function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +let nextRpc = 0 + +export function ok(value: T): RpcResponse { + return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } } +} + +export function err(error: RpcError): RpcResponse { + return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } } +} + +/** Successful generated Remote result for programmable domain fakes. */ +function remoteOk(value: T): RemoteResult { + return { ok: true, value } +} + +type ValueStreamItem = + | { kind: 'frame'; value: F; delivered?: () => void } + | { kind: 'end' } + | { kind: 'fail'; error: unknown } + +interface ValueStreamConn { + feed(item: ValueStreamItem): void +} + +interface OpenValueStream { + readonly values: AsyncGenerator + dispose(): void +} + +/** + * Commands Remote double: the generated face delivers the carrier's outcome, so + * a test that programs nothing sees an empty catalog and an unmatched line. + * @returns the Remote namespaces the session cluster calls. + */ +export type RuntimeRemotes = SessionRemotes & { readonly workspace: WorkspaceRemote } + +export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes { + return api.sessionRemotes() +} + +export class FakeApiClient implements IApiClient { + /** Chronological call record: [method, payload]. */ + readonly calls: { method: string; payload: unknown }[] = [] + /** Session ids in physical follow-generation opening order. */ + readonly followStarts: SessionId[] = [] + + // Programmable slots (defaults answer OK-empty); reassign per case. + onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [], hasMore: false })) + onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) + onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ + current: { provider: 'fixture', model: 'fixture' }, + routable: true, + groups: [], + failures: [], + })) + onSelectModel: (payload: SessionSelectModelRequest) => Promise> = + payload => Promise.resolve(ok({ + selected: { + provider: payload.provider, + model: payload.model, + ...(payload.reasoningEffort === undefined + ? {} + : { reasoningEffort: payload.reasoningEffort }), + }, + })) + onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) + onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) + onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number }) + => Promise> = + () => Promise.resolve(ok({ events: [], hasMore: false })) + + onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onAttachment: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) + onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + + onDescribe: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ + version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, + })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) + onOpenPath: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ opened: true as const })) + + onListDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) + + onCreateDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake/new' })) + + private readonly followConns = new Map[]>() + private readonly controlConns: ValueStreamConn[] = [] + private readonly workspaceConns: ValueStreamConn[] = [] + private readonly openingPages = new Map>>() + /** Optional Host opening cursor override for stale-page and reconnect tests. */ + followCursor: number | undefined + controlBaseline: SessionControlBaseline = { + queues: {}, + jobs: {}, + projections: {}, + } + workspaceBaseline: Extract['value'] = { + items: [], + archivedSessionIds: [], + } + lastSearchSignal: AbortSignal | undefined + + onSubagentList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + onSubagentPrompt: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ messageId: 'fake-message' as never })) + + onSubagentInterrupt: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ accepted: true as const })) + + readonly subagents: IApiClient['subagents'] = { + list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)), + prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)), + interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)), + } + + readonly host: IApiClient['host'] = { + describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), + pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)), + createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), + openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), + } + + onWorkspaceCreate: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true })) + + onWorkspaceRename: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) + + onWorkspaceDelete: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ deleted: true })) + + onWorkspaceInsertBefore: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspaceIds: [] })) + + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) + + onWorkspaceArchiveSession: (payload: unknown) => Promise> = + payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) + + // Payloads stay `unknown` (lint-lane note above); response rows are the real + // wire shapes so cases can program requires-bearing catalogs and dual-address + // skill lists without casts. + onSkillList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ skills: [] })) + + + readonly agentPresets: IApiClient['agentPresets'] = { + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), + select: (payload: { agentPreset: string }) => + this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + read: (payload: { agentPreset: string }) => + this.record('agentPreset.read', payload, Promise.resolve(ok({ + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', + }))), + copy: (payload: { agentPreset: string }) => + this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), + remove: (payload: { agentPreset: string }) => + this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), + } + + readonly skills: IApiClient['skills'] = { + list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), + } + + readonly goals: IApiClient['goals'] = { + create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), + } + + readonly settings: IApiClient['settings'] = { + describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))), + openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), + update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + } + + readonly credentials: IApiClient['credentials'] = { + describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))), + set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))), + unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))), + } + + readonly llm: IApiClient['llm'] = { + providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), + models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))), + discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), + } + + /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */ + sessionRemotes(): RuntimeRemotes { + return { + $stream: (options: RemoteStreamOptions) => ( + new RemoteStream(AVAILABLE_STREAM_CONNECTION, options) + ), + commands: { + execute: () => Promise.resolve({ ok: true, value: undefined }), + }, + session: { + list: payload => this.remoteResult('session.list', payload, this.onList(payload)), + search: (payload, signal) => { + this.lastSearchSignal = signal + return this.remoteResult('session.search', payload, this.onSearch(payload)) + }, + create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)), + models: payload => this.remoteResult('session.models', payload, this.onModels(payload)), + selectModel: payload => this.remoteResult( + 'session.selectModel', + payload, + this.onSelectModel(payload), + ), + rename: payload => this.remoteResult('session.rename', payload, this.onRename(payload)), + fork: payload => this.remoteResult('session.fork', payload, this.onFork(payload)), + prompt: payload => this.remoteResult('session.prompt', payload, this.onPrompt(payload)), + attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)), + updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)), + cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)), + page: request => this.page(request), + follow: (request, signal) => this.openFollow(request, signal), + control: signal => this.openControl(signal), + }, + workspace: { + create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), + rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + delete: payload => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), + insertBefore: payload => this.record( + 'workspace.insertBefore', + payload, + this.onWorkspaceInsertBefore(payload), + ), + insertSessionBefore: payload => this.record( + 'workspace.insertSessionBefore', + payload, + this.onWorkspaceInsertSessionBefore(payload), + ), + archiveSession: payload => this.record( + 'workspace.archiveSession', + payload, + this.onWorkspaceArchiveSession(payload), + ), + follow: signal => this.openWorkspace(signal), + }, + } + } + + /** Push one live Session event to every follower of that Session. */ + async pushFollow( + sessionId: SessionId, + frame: Extract, + ): Promise { + await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise((resolve) => { + conn.feed({ kind: 'frame', value: frame, delivered: resolve }) + }))) + } + + /** Push one Host-wide control update. */ + pushControl(frame: Exclude): void { + for (const conn of [...this.controlConns]) conn.feed({ kind: 'frame', value: frame }) + } + + /** Push one Workspace projection increment. */ + pushWorkspace(frame: Exclude): void { + for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'frame', value: frame }) + } + + /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ + endStreams(): void { + for (const conns of this.followConns.values()) { + for (const conn of [...conns]) conn.feed({ kind: 'end' }) + } + for (const conn of [...this.controlConns]) conn.feed({ kind: 'end' }) + for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'end' }) + } + + failStreams(error: unknown): void { + for (const conns of this.followConns.values()) { + for (const conn of [...conns]) conn.feed({ kind: 'fail', error }) + } + for (const conn of [...this.controlConns]) conn.feed({ kind: 'fail', error }) + for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'fail', error }) + } + + callsOf(method: string): unknown[] { + return this.calls.filter(c => c.method === method).map(c => c.payload) + } + + /** Number of currently attached journal generations for one Session. */ + activeFollows(sessionId: SessionId): number { + return this.followConns.get(sessionId)?.length ?? 0 + } + + private record(method: string, payload: unknown, response: Promise): Promise { + this.calls.push({ method, payload }) + return response + } + + private async remoteResult( + method: string, + payload: unknown, + response: Promise>, + ): Promise> { + return (await this.record(method, payload, response)).result + } + + private page(request: SessionPageRequest): Promise> { + const key = addressKey(request.address) + if (request.beforeSeq === undefined && request.maxMessages === 50) { + const opening = this.openingPages.get(key) + if (opening !== undefined) { + this.openingPages.delete(key) + return this.fetchPage(request, opening) + } + } + return this.fetchPage(request) + } + + private async fetchPage( + request: SessionPageRequest, + response?: Promise>, + ): Promise> { + const sessionId = addressSessionId(request.address) + const payload = request.address.kind === 'session' + ? { + sessionId, + throughSeq: request.throughSeq, + ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, + ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, + } + : { + parentSessionId: request.address.parentSessionId, + childSessionId: request.address.childSessionId, + mode: request.address.mode, + throughSeq: request.throughSeq, + ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, + ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, + } + const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history' + const result = await this.remoteResult(method, payload, response ?? this.onHistory({ + sessionId, + throughSeq: request.throughSeq, + ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, + ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, + })) + if (!result.ok) return result + return { + ok: true, + value: { + ...result.value, + events: result.value.events.filter(entry => entry.event.seq <= request.throughSeq), + }, + } + } + + private async *openFollow( + request: SessionFollowRequest, + signal: AbortSignal = new AbortController().signal, + ): AsyncGenerator { + const sessionId = addressSessionId(request.address) + this.followStarts.push(sessionId) + const key = addressKey(request.address) + const initialPage = this.followCursor === undefined + ? this.onHistory({ sessionId, maxMessages: 50 }) + : undefined + if (initialPage !== undefined) this.openingPages.set(key, initialPage) + const conns = this.followConns.get(sessionId) ?? [] + if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns) + const stream = this.openValueStream(conns, signal) + try { + const page = initialPage === undefined ? undefined : (await initialPage).result + const cursor = this.followCursor + ?? (page?.ok ? page.value.events.at(-1)?.event.seq ?? -1 : -1) + yield { type: 'opened', cursor } + yield* stream.values + } finally { + stream.dispose() + if (initialPage !== undefined && this.openingPages.get(key) === initialPage) { + this.openingPages.delete(key) + } + } + } + + private async *openControl( + signal: AbortSignal = new AbortController().signal, + ): AsyncGenerator { + const stream = this.openValueStream(this.controlConns, signal) + try { + yield { type: 'baseline', value: this.controlBaseline } + yield* stream.values + } finally { + stream.dispose() + } + } + + private async *openWorkspace( + signal: AbortSignal = new AbortController().signal, + ): AsyncGenerator { + const stream = this.openValueStream(this.workspaceConns, signal) + try { + yield { type: 'baseline', value: this.workspaceBaseline } + yield* stream.values + } finally { + stream.dispose() + } + } + + private openValueStream( + registry: ValueStreamConn[], + signal: AbortSignal, + ): OpenValueStream { + const inbox: ValueStreamItem[] = [] + let wake: (() => void) | null = null + let inFlightDelivered: (() => void) | undefined + let disposed = false + const conn: ValueStreamConn = { + feed: (item) => { + inbox.push(item) + wake?.() + }, + } + registry.push(conn) + const dispose = (): void => { + if (disposed) return + disposed = true + inFlightDelivered?.() + for (const item of inbox) { + if (item.kind === 'frame') item.delivered?.() + } + const index = registry.indexOf(conn) + if (index >= 0) registry.splice(index, 1) + wake?.() + } + const values = (async function* (): AsyncGenerator { + try { + while (!signal.aborted && !disposed) { + while (inbox.length > 0) { + const item = inbox.shift() as ValueStreamItem + if (item.kind === 'end') return + if (item.kind === 'fail') throw item.error + inFlightDelivered = item.delivered + yield item.value + inFlightDelivered?.() + inFlightDelivered = undefined + } + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + wake = null + } + } finally { + dispose() + } + })() + return { values, dispose } + } + +} diff --git a/packages/client/runtime/tests/lineage.client.spec.ts b/packages/api/session-controller/tests/lineage.client.spec.ts similarity index 94% rename from packages/client/runtime/tests/lineage.client.spec.ts rename to packages/api/session-controller/tests/lineage.client.spec.ts index 01ecacd1e2..b15b89e61b 100644 --- a/packages/client/runtime/tests/lineage.client.spec.ts +++ b/packages/api/session-controller/tests/lineage.client.spec.ts @@ -12,7 +12,7 @@ const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ ...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}), }) -describe('flattenLineage', () => { +describe('Session lineage flattening', () => { it('keeps established root and sibling order while expanding children DFS with depth', () => { const out = flattenLineage([ s('old-root', 10), @@ -54,7 +54,7 @@ describe('flattenLineage', () => { }) it('projects the completion-reminder set into rows (absent = false)', () => { - const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId])) + const out = flattenLineage([s('a', 10), s('b', 20)], new Set(['b' as SessionId])) expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false) expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true) expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false) diff --git a/packages/client/runtime/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts similarity index 57% rename from packages/client/runtime/tests/manager.client.spec.ts rename to packages/api/session-controller/tests/manager.client.spec.ts index d0594032df..26443dbb5f 100644 --- a/packages/client/runtime/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -1,13 +1,15 @@ /** * SessionManager orchestration: lazy resident instances, list lifecycle, host - * frame routing, and the pending-frame buffer for uninstantiated sessions. + * frame routing, and control baselines for uninstantiated sessions. */ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' -import { entries, ev, plainTurn } from './event-script.client.ts' +import { entries, plainTurn } from './event-script.client.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId @@ -16,6 +18,7 @@ type SummaryOver = Partial<{ updatedAt: number running: boolean blank: boolean + cwd: string parentSessionId: SessionId origin: 'subagent' }> @@ -24,55 +27,22 @@ function summary(sessionId: SessionId, over: SummaryOver = {}) { return { sessionId, updatedAt: 100, running: false, blank: false, ...over } } -describe('instances', () => { +function makeManager(): SessionManager { + const api = new FakeApiClient() + return new SessionManager(api, fakeRemote(api)) +} + +describe('SessionManager instances', () => { it('lazily builds one resident instance per id and syncs the running bit from the list', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() const session = manager.get(S1) expect(manager.get(S1)).toBe(session) // resident: same instance forever expect(session.getSnapshot().running).toBe(true) // list preceded instantiation }) - it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => { - const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - // Uninstantiated: approval buffers, plain session/event drops. - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } }) - const session = manager.get(S1) - expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }]) - // Buffer cleared: a second instantiation of another id gets nothing. - expect(manager.get(S2).getSnapshot().pending).toEqual([]) - }) - - it('retains every live answerable request and compacts resolutions before instantiation', () => { - const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - for (let i = 0; i < 40; i++) { - manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) - } - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') - for (let i = 0; i < 40; i++) { - manager.handleMuxEnvelope({ - rpcId: `r${i}` as never, - payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' }, - }) - } - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - expect(manager.get(S1).getSnapshot().pending).toEqual([]) - }) - - it('drops buffered answerable requests on session removal', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - // Removed session: buffered frames must not replay on a future instantiation. - manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } }) - manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) - expect(manager.get(S2).getSnapshot().pending).toEqual([]) - }) }) describe('list lifecycle', () => { @@ -80,7 +50,7 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const first = manager.refreshList() const second = manager.refreshList() expect(manager.getListSnapshot().state).toBe('loading') @@ -96,12 +66,9 @@ describe('list lifecycle', () => { const api = new FakeApiClient() const first = deferred>>() api.onList = () => first.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const hydration = manager.refreshList() - manager.handleHostEnvelope({ - rpcId: 'during-first' as never, - payload: { type: 'host/session-added', blank: true, sessionId: S2 }, - }) + manager.handleSessionAdded(summary(S2, { blank: true })) first.resolve(ok({ items: [summary(S1)] as never[] })) await hydration expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) @@ -113,50 +80,20 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) - it('advances list activity only for direct user messages', async () => { + it('advances list activity from the filtered Host notification', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() - // Both a new prompt and an admitted steer land as a user-sourced message. - const activity = { ...ev.user(10, 'new'), time: 500 } - manager.handleMuxEnvelope({ - rpcId: 'activity' as never, - payload: { type: 'session/event', sessionId: S1, event: activity }, - }) - expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) - - manager.handleMuxEnvelope({ - rpcId: 'older' as never, - payload: { type: 'session/event', sessionId: S1, event: { ...activity, time: 400 } }, - }) - manager.handleMuxEnvelope({ - rpcId: 'assistant' as never, - payload: { type: 'session/event', sessionId: S1, event: { ...ev.assistant(11, 0, 'reply'), time: 600 } }, - }) - - const injected = ev.user(12, 'context') - if (injected.type !== 'user/message') throw new Error('user builder returned another event type') - manager.handleMuxEnvelope({ - rpcId: 'injected' as never, - payload: { - type: 'session/event', - sessionId: S1, - event: { - ...injected, - time: 700, - data: { ...injected.data, source: { kind: 'plugin', plugin: 'test' } }, - }, - }, - }) + manager.handleSessionActivity(S1, 500) expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) }) it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) // A failed pull does not step the arrival phase: still pending. @@ -165,7 +102,7 @@ describe('list lifecycle', () => { it('phase steps pending → ready on the first successful pull and never returns', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) expect(manager.getListSnapshot().phase).toBe('pending') await manager.refreshList() expect(manager.getListSnapshot().phase).toBe('ready') @@ -184,7 +121,7 @@ describe('list lifecycle', () => { it('merges create into the list immediately without waiting for a refresh', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S2 })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const result = await manager.create() expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) @@ -192,16 +129,13 @@ describe('list lifecycle', () => { it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - const titleFrame = (rpcId: string, title: string, seq: number) => { - manager.handleMuxEnvelope({ - rpcId: rpcId as never, - payload: { type: 'session/projection', sessionId: S1, key: 'title', value: title, seq } as never, - }) + const manager = new SessionManager(api, fakeRemote(api)) + const titleFrame = (title: string, seq: number) => { + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq }) } - titleFrame('title-new', 'Newest', 4) - titleFrame('title-stale', 'Stale', 3) - titleFrame('title-equal', 'Equal', 4) + titleFrame('Newest', 4) + titleFrame('Stale', 3) + titleFrame('Equal', 4) api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[], })) @@ -212,18 +146,17 @@ describe('list lifecycle', () => { expect(titled.items[0]?.title).toBe('Newest') expect(titled.items[1]?.title).toBeUndefined() - manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) - manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) + manager.handleSessionRemoved(S1) + manager.handleSessionAdded(summary(S1, { blank: true })) expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) // A push frame landed before the list (S2's title is newer than the block's cut). - manager.handleMuxEnvelope({ - rpcId: 'push-newer' as never, - payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9, }) api.onList = () => Promise.resolve(ok({ items: [ @@ -242,23 +175,33 @@ describe('list lifecycle', () => { it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() - const frame = (rpcId: string, payload: object) => { - manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never }) - } - frame('title-unflushed', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 }) + const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) } + frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 }) // The durable baseline says the host only knows up to seq 2: the phantom // row rode lost state and must drop, or last-wins pins it forever. - frame('subscribed-recovered', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 }) + frame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, + projections: { [S1]: { asOfSeq: 2, values: {} } }, + }, + }) expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() - frame('title-durable', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 }) + frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 }) expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') // A baseline at or past the row's seq keeps it (nothing phantom to drop). - frame('subscribed-current', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 }) + frame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, + projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } }, + }, + }) expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') }) }) @@ -270,7 +213,7 @@ describe('search', () => { items: [{ sessionId: S1, snippet: 'matching excerpt' }], hasMore: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const signal = new AbortController().signal await expect(manager.search('exact phrase', signal)).resolves.toEqual({ @@ -286,7 +229,7 @@ describe('search', () => { it('preserves business errors and folds transport failures', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) api.onSearch = () => Promise.resolve(err({ code: 'internal', message: 'index unavailable', @@ -306,23 +249,23 @@ describe('search', () => { }) }) -describe('host frame routing', () => { - it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => { +describe('Host Remote event routing', () => { + it('adds/removes/flips sessions and keeps removed instances resident', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) - manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored + const manager = new SessionManager(api, fakeRemote(api)) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored expect(manager.getListSnapshot().items).toHaveLength(1) const session = manager.get(S1) - manager.handleHostEnvelope({ rpcId: 'h3' as never, payload: { type: 'host/session-status', sessionId: S1, running: true } }) + manager.handleSessionStatus(S1, true) expect(session.getSnapshot().running).toBe(true) expect(manager.getListSnapshot().items[0]?.running).toBe(true) - manager.handleHostEnvelope({ rpcId: 'h4' as never, payload: { type: 'host/agent-error', sessionId: S1, message: '炸了' } }) + manager.handleSessionError(S1, '炸了') expect(session.getSnapshot().lastAgentError).toBe('炸了') - manager.handleHostEnvelope({ rpcId: 'h5' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + manager.handleSessionRemoved(S1) expect(manager.getListSnapshot().items).toHaveLength(0) expect(session.getSnapshot().removed).toBe(true) expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal @@ -343,7 +286,7 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() await manager.refreshSubagents(S1) manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) @@ -368,7 +311,7 @@ describe('subagent catalogs', () => { await manager.get(S2).open() await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') expect(api.callsOf('subagent.history')).toEqual([ - { parentSessionId: S1, childSessionId: S2, mode: 'continuable', maxMessages: 50 }, + { parentSessionId: S1, childSessionId: S2, mode: 'continuable', throughSeq: -1, maxMessages: 50 }, ]) expect(api.callsOf('subagent.prompt')).toEqual([ { @@ -380,19 +323,13 @@ describe('subagent catalogs', () => { expect(api.callsOf('session.history')).toEqual([]) expect(api.callsOf('session.prompt')).toEqual([]) const listCalls = api.callsOf('subagent.list').length - manager.handleHostEnvelope({ - rpcId: 'child-complete' as never, - payload: { type: 'host/session-status', sessionId: S2, running: false }, - }) + manager.handleSessionStatus(S2, false) expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({ kind: 'child', id: S2, activity: 'inactive', }) expect(api.callsOf('subagent.list')).toHaveLength(listCalls) - manager.handleHostEnvelope({ - rpcId: 'child-detached' as never, - payload: { type: 'host/session-removed', sessionId: S2 }, - }) + manager.handleSessionRemoved(S2) expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({ origin: 'subagent', parentSessionId: S1, running: false, }) @@ -408,33 +345,18 @@ describe('subagent catalogs', () => { vi.useFakeTimers() try { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshSubagents(S1) manager.setSubagentCatalogOpen(S1, true) await Promise.resolve() const baseline = api.callsOf('subagent.list').length - manager.handleHostEnvelope({ - rpcId: 'child-added' as never, - payload: { - type: 'host/session-added', sessionId: S2, parentSessionId: S1, blank: false, - }, - }) - manager.handleHostEnvelope({ - rpcId: 'child-added-again' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-m3' as SessionId, parentSessionId: S1, blank: false, - }, - }) + manager.handleSessionAdded(summary(S2, { parentSessionId: S1 })) + manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 })) await vi.advanceTimersByTimeAsync(50) expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1) manager.setSubagentCatalogOpen(S1, false) - manager.handleHostEnvelope({ - rpcId: 'child-added-closed' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-m4' as SessionId, parentSessionId: S1, blank: false, - }, - }) + manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 })) await vi.advanceTimersByTimeAsync(50) expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1) } finally { @@ -458,23 +380,13 @@ describe('subagent catalogs', () => { ] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshSubagents(root) - manager.handleHostEnvelope({ - rpcId: 'nested-subagent' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId, - parentSessionId: S1, origin: 'subagent', blank: false, - }, - }) - manager.handleHostEnvelope({ - rpcId: 'ordinary-fork' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-fork' as SessionId, - parentSessionId: S2, blank: false, - }, - }) + manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { + parentSessionId: S1, origin: 'subagent', + })) + manager.handleSessionAdded(summary('fk-fork' as SessionId, { parentSessionId: S2 })) expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ { kind: 'child', id: S1, hasChildren: true }, @@ -487,16 +399,12 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshSubagents(root) - manager.handleHostEnvelope({ - rpcId: 'nested-subagent' as never, - payload: { - type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId, - parentSessionId: S1, origin: 'subagent', blank: false, - }, - }) + manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { + parentSessionId: S1, origin: 'subagent', + })) response.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', @@ -528,17 +436,11 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const response = deferred>>() api.onSubagentList = () => response.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshSubagents(root) - manager.handleHostEnvelope({ - rpcId: 'child-stopped' as never, - payload: { type: 'host/session-status', sessionId: S1, running: false }, - }) - manager.handleHostEnvelope({ - rpcId: 'child-started' as never, - payload: { type: 'host/session-status', sessionId: S2, running: true }, - }) + manager.handleSessionStatus(S1, false) + manager.handleSessionStatus(S2, true) response.resolve(ok({ entries: [ { @@ -569,13 +471,10 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshSubagents(S1) - manager.handleHostEnvelope({ - rpcId: 'child-detached' as never, - payload: { type: 'host/session-removed', sessionId: S2 }, - }) + manager.handleSessionRemoved(S2) expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([ { kind: 'child', id: S2, activity: 'inactive' }, @@ -587,7 +486,7 @@ describe('subagent catalogs', () => { const root = 'fk-root' as SessionId const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshSubagents(root) expect(manager.refreshSubagents(root)).toBe(refresh) @@ -606,19 +505,14 @@ describe('subagent catalogs', () => { const first = deferred>>() const second = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, fakeRemote(), root) + const manager = new SessionManager(api, fakeRemote(api), root) const refresh = manager.refreshSubagents(root) // A membership frame arrives while the pull is in flight; the debounced // refresh it schedules fires 50ms later and is coalesced into the pull — // which was requested before the new child existed. The stale mark must // queue one trailing pull carrying the change. - manager.handleHostEnvelope({ - rpcId: 'child-added' as never, - payload: { - type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false, - }, - }) + manager.handleSessionAdded(summary(S2, { parentSessionId: root })) await vi.advanceTimersByTimeAsync(50) api.onSubagentList = () => second.promise first.resolve(ok({ @@ -664,7 +558,7 @@ describe('subagent catalogs', () => { }) const first = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshSubagents(root) first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await refresh @@ -675,10 +569,7 @@ describe('subagent catalogs', () => { const mid = deferred>>() api.onSubagentList = () => mid.promise const midRefresh = manager.refreshSubagents(root) - manager.handleHostEnvelope({ - rpcId: 'parent-removed-mid-pull' as never, - payload: { type: 'host/session-removed', sessionId: root }, - }) + manager.handleSessionRemoved(root) const trailing = deferred>>() api.onSubagentList = () => trailing.promise mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) @@ -711,15 +602,12 @@ describe('subagent catalogs', () => { }] as never[], parentAvailable: true, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshSubagents(root) manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) - manager.handleHostEnvelope({ - rpcId: 'parent-removed' as never, - payload: { type: 'host/session-removed', sessionId: root }, - }) + manager.handleSessionRemoved(root) expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) @@ -730,14 +618,14 @@ describe('remaining branches', () => { it('refreshList folds a transport throw into the error state', async () => { const api = new FakeApiClient() api.onList = () => Promise.reject(new Error('list wire down')) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) }) it('refreshList pushes running bits down to already-instantiated sessions', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const session = manager.get(S1) api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) await manager.refreshList() @@ -747,7 +635,7 @@ describe('remaining branches', () => { it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.create({ cwd: '/tmp/w', sessionId: S1 }) expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) @@ -767,7 +655,7 @@ describe('remaining branches', () => { message: 'published but unattached', details: { sessionId: S1, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) @@ -781,7 +669,7 @@ describe('remaining branches', () => { message: 'forked but unattached', details: { sessionId: S2, workspaceId: 'w1' }, } as never)) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const result = await manager.fork({ sessionId: S1 }) expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ @@ -794,28 +682,22 @@ describe('remaining branches', () => { it('reconciles a preallocated id after an ordinary transport failure', async () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) expect(manager.getListSnapshot().items).toEqual([]) - manager.handleHostEnvelope({ - rpcId: 'published-later' as never, - payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' }, - }) + manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' })) expect(manager.getListSnapshot().items).toEqual([ expect.objectContaining({ sessionId: S1, cwd: '/w/one' }), ]) - manager.handleHostEnvelope({ - rpcId: 'duplicate-frame' as never, - payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' }, - }) + manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' })) expect(manager.getListSnapshot().items).toHaveLength(1) }) it('subscribe notifies on list changes and stops after unsubscribe', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) let notified = 0 const unsubscribe = manager.subscribe(() => { notified++ }) await manager.refreshList() @@ -823,53 +705,42 @@ describe('remaining branches', () => { expect(notified).toBeGreaterThan(0) const seen = notified unsubscribe() - manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) + manager.handleSessionAdded(summary(S1, { blank: true })) await new Promise(resolve => setTimeout(resolve, 0)) expect(notified).toBe(seen) }) - it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => { + it('ignores Host status and error events for sessions without an instance', () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) - manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } }) - manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never }) - const session = manager.get(S1) - manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) - expect(session.getSnapshot().pending).toMatchObject([{ kind: 'question' }]) - // status flip for an unknown session only touches summaries (no crash). - manager.handleHostEnvelope({ rpcId: 'h9' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } }) - manager.handleHostEnvelope({ rpcId: 'ha' as never, payload: { type: 'host/agent-error', sessionId: S2, message: '无实例' } }) + const manager = new SessionManager(api, fakeRemote(api)) + manager.handleSessionStatus(S2, true) + manager.handleSessionError(S2, '无实例') }) it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() const before = manager.getListSnapshot() - manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } }) + manager.handleSessionStatus(S2, true) const after = manager.getListSnapshot() expect(after.items).not.toBe(before.items) const beforeS1 = before.items.find(e => e.sessionId === S1) const afterS1 = after.items.find(e => e.sessionId === S1) expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache) // Same-order same-entries snapshot reuses the items array. - manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/agent-error', sessionId: S1, message: 'x' } }) + manager.handleSessionError(S1, 'x') expect(manager.getListSnapshot().items).toBe(after.items) }) - it('carries parentSessionId from host/session-added into the lineage row', () => { + it('carries parentSessionId from the added event into the lineage row', () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) - manager.handleHostEnvelope({ - rpcId: 'h2' as never, - payload: { - type: 'host/session-added', blank: true, sessionId: S2, - parentSessionId: S1, origin: 'subagent', - }, - }) + const manager = new SessionManager(api, fakeRemote(api)) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionAdded(summary(S2, { + blank: true, parentSessionId: S1, origin: 'subagent', + })) const items = manager.getListSnapshot().items expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, origin: 'subagent', depth: 1, @@ -878,14 +749,14 @@ describe('remaining branches', () => { }) describe('connected generation', () => { - it('refreshes the list and resyncs only opened instances', async () => { + it('refreshes query baselines without rebuilding independently resumed Session sources', async () => { const api = new FakeApiClient() api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const openedSession = manager.get(S1) await openedSession.open() manager.get(S2) // instantiated but never opened @@ -893,9 +764,8 @@ describe('connected generation', () => { manager.handleConnected() await vi.waitFor(() => { expect(api.callsOf('session.list').length).toBe(1) - // Only the opened instance repulls history; the cold one stays silent. - expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1) }) + expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore) }) it('reloads the durable parent address for a restored child selection', async () => { @@ -903,7 +773,7 @@ describe('connected generation', () => { const address = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, } - const manager = new SessionManager(api, fakeRemote(), S2, address) + const manager = new SessionManager(api, fakeRemote(api), S2, address) manager.handleConnected() @@ -914,139 +784,24 @@ describe('connected generation', () => { }) }) -describe('pending-interaction list status', () => { - it('tracks approval requests through replay and resolution without instantiation', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') - // Mux-open replay of the same question (same approvalId) is idempotent. - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') - manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - }) - - it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - manager.handleMuxEnvelope({ - rpcId: 'q1' as never, - payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, - }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') - manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - - manager.handleMuxEnvelope({ - rpcId: 'q2' as never, - payload: { - type: 'question/requested', - sessionId: S1, - questions: [{ - id: 'plan', question: 'Approve?', detail: '# Plan', - options: [{ label: 'Approve' }, { label: 'Refuse' }], - intent: { kind: 'plan-review', approve: 'Approve' }, - }], - }, - }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review') - manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - }) - - it.each([ - ['missing detail', {}], - ['multi-select', { detail: '# Plan', multiSelect: true }], - ['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }], - ['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }], - ])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - manager.handleMuxEnvelope({ - rpcId: 'q-plan' as never, - payload: { - type: 'question/requested', sessionId: S1, - questions: [{ - id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }], - intent: { kind: 'plan-review', approve: 'Approve' }, - ...over, - }], - }, - }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') - }) - - it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ - rpcId: 'q1' as never, - payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] }, - }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question') - manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') - manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - - manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) - manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) - expect(manager.getListSnapshot().items).toHaveLength(0) - }) - - it('drops stale status at generation death before replay re-adds live interactions', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') - // Generation death clears (resolved-while-disconnected questions send no frame)… - manager.handleDisconnected() - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined() - // …and a replayed frame arriving before onConnected (stream open precedes - // the readiness handshake) survives the later handleConnected untouched. - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - manager.handleConnected() - expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval') - }) - - it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } }) - // Buffered pre-instantiation: an approval pair and a queued row. - manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) - manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } }) - manager.handleDisconnected() - // Instantiate after the death sweep: no zombie interaction replays (the - // pendingBuffers held only dead-generation rpcIds), so the session mints - // no pending waits. - const session = manager.get(S1) - expect(session.getSnapshot().pending).toEqual([]) - }) -}) - describe('completed reminder', () => { - const status = (rpcId: string, sessionId: SessionId, running: boolean) => ({ - rpcId: rpcId as never, - payload: { type: 'host/session-status' as const, sessionId, running }, - }) - const added = (rpcId: string, sessionId: SessionId) => ({ - rpcId: rpcId as never, - payload: { type: 'host/session-added' as const, sessionId, blank: false }, - }) + const status = (manager: SessionManager, sessionId: SessionId, running: boolean): void => { + manager.handleSessionStatus(sessionId, running) + } + const added = (manager: SessionManager, sessionId: SessionId): void => { + manager.handleSessionAdded(summary(sessionId)) + } const entry = (manager: SessionManager, sessionId: SessionId) => manager.getListSnapshot().items.find(item => item.sessionId === sessionId) it('arms on a running→idle flip of a non-selected session and clears on select', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope(added('h1', S1)) - manager.handleHostEnvelope(added('h2', S2)) + const manager = makeManager() + added(manager, S1) + added(manager, S2) manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) - manager.handleHostEnvelope(status('s1', S2, true)) - manager.handleHostEnvelope(status('s2', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) // Opening the session consumes the reminder. manager.select(S2) @@ -1054,53 +809,53 @@ describe('completed reminder', () => { }) it('never arms for the session being watched and re-arms after a switch-away re-run', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope(added('h1', S1)) - manager.handleHostEnvelope(added('h2', S2)) + const manager = makeManager() + added(manager, S1) + added(manager, S2) manager.select(S2) - manager.handleHostEnvelope(status('s1', S2, true)) - manager.handleHostEnvelope(status('s2', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder // Switch away; a fresh run completing again arms the reminder. manager.select(S1) - manager.handleHostEnvelope(status('s3', S2, true)) - manager.handleHostEnvelope(status('s4', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) }) it('a re-run disarms the reminder while running and re-arms on its completion', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope(added('h1', S1)) - manager.handleHostEnvelope(added('h2', S2)) + const manager = makeManager() + added(manager, S1) + added(manager, S2) manager.select(S1) - manager.handleHostEnvelope(status('s1', S2, true)) - manager.handleHostEnvelope(status('s2', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) // The user starts a new run without opening the session: running wins. - manager.handleHostEnvelope(status('s3', S2, true)) + status(manager, S2, true) expect(entry(manager, S2)?.completed).toBe(false) - manager.handleHostEnvelope(status('s4', S2, false)) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) }) it('session-removed drops the reminder and a re-add starts clean', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope(added('h1', S1)) - manager.handleHostEnvelope(added('h2', S2)) + const manager = makeManager() + added(manager, S1) + added(manager, S2) manager.select(S1) - manager.handleHostEnvelope(status('s1', S2, true)) - manager.handleHostEnvelope(status('s2', S2, false)) + status(manager, S2, true) + status(manager, S2, false) expect(entry(manager, S2)?.completed).toBe(true) - manager.handleHostEnvelope({ rpcId: 'rm' as never, payload: { type: 'host/session-removed', sessionId: S2 } }) + manager.handleSessionRemoved(S2) expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined() - manager.handleHostEnvelope(added('h3', S2)) + added(manager, S2) expect(entry(manager, S2)?.completed).toBe(false) }) it('a list refresh carrying the running→idle transition arms the reminder', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -1112,7 +867,7 @@ describe('completed reminder', () => { it('never arms for sessions already idle at first observation', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) await manager.refreshList() manager.select(S1) expect(entry(manager, S2)?.completed).toBe(false) @@ -1125,11 +880,11 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshList() // The session finishes while the first pull is still in flight; the pull // response recorded it as running at pull time. - manager.handleHostEnvelope(status('s-mid', S2, false)) + status(manager, S2, false) gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) await refresh expect(entry(manager, S2)?.completed).toBe(true) @@ -1139,13 +894,13 @@ describe('completed reminder', () => { const api = new FakeApiClient() const gate = deferred>>() api.onList = () => gate.promise - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) const refresh = manager.refreshList() // The unknown session starts and finishes while the first pull is in // flight; the pull-time baseline recorded it idle, so the running→idle // edge lives entirely inside the replayed mutations. - manager.handleHostEnvelope(status('s-start', S2, true)) - manager.handleHostEnvelope(status('s-finish', S2, false)) + status(manager, S2, true) + status(manager, S2, false) gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) await refresh expect(entry(manager, S2)?.completed).toBe(true) @@ -1156,53 +911,57 @@ describe('background-job mirror', () => { const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, }) - const tasksFrame = (sessionId: SessionId, jobs: unknown[]) => - ({ rpcId: 't' as never, payload: { type: 'session/jobs', sessionId, jobs } as never }) + const tasksFrame = ( + sessionId: SessionId, + jobs: unknown[], + ): Extract => ({ + type: 'jobs', sessionId, jobs: jobs as never, + }) it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) - manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) + const manager = makeManager() + manager.handleControlFrame(tasksFrame(S1, [view()])) + manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) const first = manager.getListSnapshot().jobsBySession expect(first[S1]).toEqual([view()]) expect(first[S2]?.[0]?.label).toBe('other') // Last-wins: the newer whole set replaces, it does not merge. - manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })])) + manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })])) expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })]) }) it('stores an emptied set as an absent key so absence and [] read alike', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + const manager = makeManager() + manager.handleControlFrame(tasksFrame(S1, [view()])) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true) - manager.handleMuxEnvelope(tasksFrame(S1, [])) + manager.handleControlFrame(tasksFrame(S1, [])) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) - it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) - manager.handleMuxEnvelope({ - rpcId: 's' as never, - payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 }, + it('clears the mirror when the next control baseline has no jobs', () => { + const manager = makeManager() + manager.handleControlFrame(tasksFrame(S1, [view()])) + manager.handleControlFrame({ + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, }) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) it('drops the rows when the session is removed, whichever stream lands first', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) - manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + const manager = makeManager() + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleControlFrame(tasksFrame(S1, [view()])) + manager.handleSessionRemoved(S1) expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) }) it('notifies list subscribers so an open header re-renders without a poll', async () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) + const manager = makeManager() const seen = vi.fn() manager.subscribe(seen) - manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleControlFrame(tasksFrame(S1, [view()])) // The notifier batches on a microtask; the frame itself is already applied. await Promise.resolve() expect(seen).toHaveBeenCalled() diff --git a/packages/client/runtime/tests/notifier.client.spec.ts b/packages/api/session-controller/tests/notifier.client.spec.ts similarity index 99% rename from packages/client/runtime/tests/notifier.client.spec.ts rename to packages/api/session-controller/tests/notifier.client.spec.ts index f12d063400..dc5ca2f4ff 100644 --- a/packages/client/runtime/tests/notifier.client.spec.ts +++ b/packages/api/session-controller/tests/notifier.client.spec.ts @@ -12,7 +12,7 @@ afterEach(() => { vi.unstubAllGlobals() }) -describe('Notifier', () => { +describe('Session notifier', () => { it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => { const order: string[] = [] const notifier = new Notifier(() => order.push('rebuild')) diff --git a/packages/client/runtime/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts similarity index 81% rename from packages/client/runtime/tests/projection-store.client.spec.ts rename to packages/api/session-controller/tests/projection-store.client.spec.ts index 5ef2c41194..f12bd4475b 100644 --- a/packages/client/runtime/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -4,7 +4,7 @@ * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a * newer push frame; a replayed frame cannot regress), capability absence as * undefined, generation truncation, and the Session/manager wiring (tail-page - * seeding, session/projection frame routing pre- and post-instantiation, the + * seeding, control-stream projection routing pre- and post-instantiation, the * list rows' title projection). */ import { describe, expect, it } from 'vitest' @@ -25,7 +25,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { const SID = 'fk-s1' as SessionId -describe('ProjectionValueStore semantics', () => { +describe('Session projection value semantics', () => { it('reads undefined until a value lands (capability absence)', () => { const store = new ProjectionValueStore() expect(store.get('test/marks')).toBeUndefined() @@ -103,7 +103,7 @@ describe('ProjectionValueStore semantics', () => { describe('Session tail-page seeding', () => { it('seeds the store from a history response carrying a projections block', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) + const session = new Session(SID, api, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, @@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => { it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) + const session = new Session(SID, api, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, @@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) + const session = new Session(SID, api, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, 9) @@ -139,41 +139,41 @@ describe('Session tail-page seeding', () => { describe('manager frame routing', () => { const sid = (s: string): SessionId => s as SessionId - it('lands session/projection frames before instantiation and the Session adopts the same store', async () => { + it('lands projection frames before instantiation and the Session adopts the same store', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleMuxEnvelope({ - rpcId: 'p1' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never, + const manager = new SessionManager(api, fakeRemote(api)) + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7, }) const session = manager.get(sid('s1')) expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] }) // Frames after instantiation land in the same store. - manager.handleMuxEnvelope({ - rpcId: 'p2' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9, }) expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] }) }) - it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => { + it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) await manager.refreshList() - manager.handleMuxEnvelope({ - rpcId: 't1' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4, }) await Promise.resolve() expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title') // The durable baseline says the host only knows up to seq 2: the row rode // lost state and must drop (the un-flushed title precedent). - manager.handleMuxEnvelope({ - rpcId: 'sub' as never, - payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never, + manager.handleControlFrame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, + projections: { [sid('s1')]: { asOfSeq: 2, values: {} } }, + }, }) await Promise.resolve() expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() @@ -181,7 +181,7 @@ describe('manager frame routing', () => { it('projects every retained value into list rows with stable snapshot identity', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, @@ -196,12 +196,9 @@ describe('manager frame routing', () => { expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } }) expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline) - manager.handleMuxEnvelope({ - rpcId: 'p2' as never, - payload: { - type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', - value: { marks: ['live'] }, seq: 3, - } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', + value: { marks: ['live'] }, seq: 3, }) await Promise.resolve() expect(manager.getListSnapshot().items[0]?.projectionValues) @@ -211,19 +208,15 @@ describe('manager frame routing', () => { it('drops the projection store with the removed session', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(api, fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) await manager.refreshList() - manager.handleMuxEnvelope({ - rpcId: 't1' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never, - }) - manager.handleHostEnvelope({ - rpcId: 'rm' as never, - payload: { type: 'host/session-removed', sessionId: sid('s1') } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4, }) + manager.handleSessionRemoved(sid('s1')) expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined() }) }) diff --git a/packages/client/runtime/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts similarity index 66% rename from packages/client/runtime/tests/queue-store.client.spec.ts rename to packages/api/session-controller/tests/queue-store.client.spec.ts index da109e7da7..ed0a1568d1 100644 --- a/packages/client/runtime/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -3,11 +3,12 @@ * change, reconnect re-baselining, pre-instantiation buffering, editable-text * projection, and snapshot reference stability. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, fakeRemote } from './fake-api.client.ts' @@ -26,29 +27,39 @@ interface QueueFixture { } /** Build one authoritative queue snapshot. */ -function queueFrame(items: QueueFixture[]): MuxFrame { +function queueFrame(items: QueueFixture[]): Extract { return { - type: 'session/queue', + type: 'queue', sessionId: SID, items: items.map(item => ({ id: iid(item.id), placement: item.placement ?? 'queued', - message: item.message ?? createUserMessage({ + message: (item.message ?? createUserMessage({ content: item.content ?? text(item.body), source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, - }), + })) as never, })), } } function makeSession(): Session { - return new Session(SID, new FakeApiClient(), fakeRemote()) + return makeBench().session } -describe('queue snapshot intake', () => { +function makeBench(): { api: FakeApiClient; session: Session } { + const api = new FakeApiClient() + return { api, session: new Session(SID, api, fakeRemote(api)) } +} + +function makeManager(): SessionManager { + const api = new FakeApiClient() + return new SessionManager(api, fakeRemote(api)) +} + +describe('Session queue snapshot intake', () => { it('projects stable ids, flat previews, and complete text', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-1'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 'q-1', body: '第一条 排队\n消息' }, ])) const queue = session.getSnapshot().queue @@ -64,7 +75,7 @@ describe('queue snapshot intake', () => { it('marks mixed-content messages non-editable while retaining their preview', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-2'), queueFrame([{ + session.handleControlFrame(queueFrame([{ id: 'q-image', body: '', content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], @@ -83,7 +94,7 @@ describe('queue snapshot intake', () => { it('caps previews at 200 code points and preserves the full editable text', () => { const session = makeSession() const body = '长'.repeat(201) - session.handleMuxEnvelope(rid('env-3'), queueFrame([{ id: 'q-cap', body }])) + session.handleControlFrame(queueFrame([{ id: 'q-cap', body }])) const row = session.getSnapshot().queue[0] expect(Array.from(row?.preview ?? '')).toHaveLength(201) expect(row?.preview.endsWith('…')).toBe(true) @@ -92,11 +103,11 @@ describe('queue snapshot intake', () => { it('replaces content, order, and membership from each authoritative frame', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-4'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 'q-1', body: 'one' }, { id: 'q-2', body: 'two' }, ])) - session.handleMuxEnvelope(rid('env-5'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 'q-2', body: 'two edited' }, ])) const queue = session.getSnapshot().queue @@ -108,13 +119,13 @@ describe('queue snapshot intake', () => { preview: 'two edited', text: 'two edited', }, ]) - session.handleMuxEnvelope(rid('env-6'), queueFrame([])) + session.handleControlFrame(queueFrame([])) expect(session.getSnapshot().queue).toEqual([]) }) it('keeps the queue array reference stable across unrelated snapshot swaps', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-7'), queueFrame([{ id: 'q-stable', body: '稳定' }])) + session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }])) const before = session.getSnapshot().queue session.handleAgentError('unrelated') expect(session.getSnapshot().queue).toBe(before) @@ -122,7 +133,7 @@ describe('queue snapshot intake', () => { it('retains steering placement and complete content in the same authoritative snapshot', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-steering'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 'q-next', body: 'later' }, { id: 's-now', body: 'interrupt now', placement: 'steering' }, ])) @@ -136,13 +147,13 @@ describe('queue snapshot intake', () => { }) it('hands off exactly one current occurrence when live steering becomes durable', async () => { - const session = makeSession() + const { api, session } = makeBench() await session.open() const message = createUserMessage({ content: text('same message'), source: { kind: 'user' }, }) - session.handleMuxEnvelope(rid('env-same-id'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 's-first', body: '', placement: 'steering', message }, { id: 's-second', body: '', placement: 'steering', message }, ])) @@ -154,52 +165,53 @@ describe('queue snapshot intake', () => { data: message, } as SessionEvent - session.handleMuxEnvelope(rid('env-durable'), { - type: 'session/event', sessionId: SID, event: durable, + await api.pushFollow(SID, { type: 'event', event: durable as never }) + await vi.waitFor(() => { + expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) }) - expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) - session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 's-later', body: '', placement: 'steering', message }, ])) - session.handleMuxEnvelope(rid('env-replayed-durable'), { - type: 'session/event', sessionId: SID, event: durable, + await api.pushFollow(SID, { type: 'event', event: durable as never }) + await vi.waitFor(() => { + expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) }) - expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) }) it('hands off live steering when the agent claims it as a user message', async () => { - const session = makeSession() + const { api, session } = makeBench() await session.open() const message = createUserMessage({ content: text('claimed steering'), source: { kind: 'user' }, }) - session.handleMuxEnvelope(rid('env-claimed'), queueFrame([ + session.handleControlFrame(queueFrame([ { id: 's-claimed', body: '', placement: 'steering', message }, ])) - session.handleMuxEnvelope(rid('env-user-message'), { - type: 'session/event', - sessionId: SID, + await api.pushFollow(SID, { + type: 'event', event: { seq: 0, time: 1_700_000_000_000, type: 'user/message', surfaceOp: 'append', data: message, - }, + } as never, }) - expect(session.getSnapshot().queue).toEqual([]) + await vi.waitFor(() => { + expect(session.getSnapshot().queue).toEqual([]) + }) }) }) describe('queue operation transport', () => { it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) - session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }])) + const session = new Session(SID, api, fakeRemote(api)) + session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }])) const before = session.getSnapshot().queue await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') })) @@ -223,26 +235,26 @@ describe('queue operation transport', () => { }) describe('queue reconnect semantics', () => { - it('session/subscribed clears stale state before the fresh snapshot lands', () => { + it('a control baseline clears stale state before a fresh update lands', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }])) - session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 }) + session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }])) + session.replaceControl([]) expect(session.getSnapshot().queue).toEqual([]) - session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }])) + session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }])) expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) it('resync does not clear a baseline that raced ahead of the host connection signal', async () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) - session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }])) + await session.open() + session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }])) await session.resync() expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh']) }) it('running-status changes never guess at queue retirement', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }])) + session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }])) session.handleRunning(true) session.handleRunning(false) expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live']) @@ -251,26 +263,25 @@ describe('queue reconnect semantics', () => { describe('manager buffering of queue snapshots', () => { it('replays only the latest snapshot for an uninstantiated session', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) }) - manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) }) + const manager = makeManager() + manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }])) + manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }])) expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => { - const manager = new SessionManager(new FakeApiClient(), fakeRemote()) - manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) }) - manager.handleMuxEnvelope({ - rpcId: rid('g1b'), - payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' }, + it('a control baseline replaces the prior queue', () => { + const manager = makeManager() + manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }])) + const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items + manager.handleControlFrame({ + type: 'baseline', + value: { + queues: { [SID]: nextQueue }, + jobs: {}, + projections: {}, + }, }) - manager.handleMuxEnvelope({ - rpcId: rid('g2a'), - payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 }, - }) - manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) }) const snapshot = manager.get(SID).getSnapshot() expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2']) - expect(snapshot.pending.map(pending => pending.kind)).toEqual(['approval']) }) }) diff --git a/packages/client/runtime/tests/scope.client.spec.ts b/packages/api/session-controller/tests/scope.client.spec.ts similarity index 97% rename from packages/client/runtime/tests/scope.client.spec.ts rename to packages/api/session-controller/tests/scope.client.spec.ts index 528c36131e..a0d7be3e7e 100644 --- a/packages/client/runtime/tests/scope.client.spec.ts +++ b/packages/api/session-controller/tests/scope.client.spec.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import { createScope, scopeOf } from '../src/client/agents/scope.ts' +import { createScope, scopeOf } from '../src/client/scope.ts' const sid = (k: string): SessionId => k as SessionId diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts similarity index 75% rename from packages/host/apiproxy/tests/api-proxy-cold.spec.ts rename to packages/api/session-controller/tests/session-cold.host.spec.ts index ef642082ac..da2d77ece5 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -1,5 +1,5 @@ /** - * Cold-session and degenerate-composition paths of the host ApiProxy: + * Cold-session and degenerate-composition paths of the Session Controller: * metadata-only listing, Agent-free history reads, subagent ownership * isolation, and prompt failure mapping. */ @@ -11,27 +11,35 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' +import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { PersistenceCoordinator, SessionPersistenceRevision, type PersistenceBackend, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import { createSessionTestRemote } from './test-remote.ts' const sid = (id: string): SessionId => id as SessionId -let nextRpc = 1 -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload } +function request

(payload: P): P { + return payload +} + +let nextRequestId = 1 +function promptRequest( + payload: Omit, +): SessionPromptRequest { + return { + ...payload, + requestId: `cold-${String(nextRequestId++)}` as SessionRequestId, + } } function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { @@ -42,7 +50,6 @@ describe('sessions.list cold merge', () => { it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(UserQuestionService) const root = mkdtempSync(join(tmpdir(), 'dsh-cold-')) const smallPath = join(root, 'small.log') const largePath = join(root, 'large.log') @@ -104,12 +111,12 @@ describe('sessions.list cold merge', () => { return undefined }, } as never) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const response = await api.sessions.list(request({})) - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - const byId = Object.fromEntries(response.result.value.items.map(item => [item.sessionId, item])) + const response = await remote.list(request({})) + expect(response.ok).toBe(true) + if (!response.ok) throw new Error('unreachable') + const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item])) expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false }) // A stale true hint cannot hide the turn found in the bounded read. expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 }) @@ -135,7 +142,6 @@ describe('sessions.list cold merge', () => { it('can disable bounded blank probes without hiding cold Sessions', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(UserQuestionService) const meta = header('probe-disabled', 100) const readFrom = vi.fn() ctx.provide('sessionPersistence', { @@ -143,15 +149,15 @@ describe('sessions.list cold merge', () => { locate: () => ({ kind: 'jsonl', path: '/not-read' }), readFrom, } as never) - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', coldBlankProbeMaxBytes: 0, }) - const response = await api.sessions.list(request({})) - if (!response.result.ok) throw new Error('unreachable') - expect(response.result.value.items).toEqual([ + const response = await remote.list(request({})) + if (!response.ok) throw new Error('unreachable') + expect(response.value.items).toEqual([ expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }), ]) expect(readFrom).not.toHaveBeenCalled() @@ -160,7 +166,6 @@ describe('sessions.list cold merge', () => { it('replaces a probed cold row with the live Session that attached during the read', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) const meta = header('attached-during-probe', 100) const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-')) @@ -180,9 +185,9 @@ describe('sessions.list cold merge', () => { } }, } as never) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const listing = api.sessions.list(request({})) + const listing = remote.list(request({})) await started.promise const session = ctx.sessions.create(meta.id, { seed: [ @@ -202,8 +207,8 @@ describe('sessions.list cold merge', () => { release.resolve(undefined) const response = await listing - if (!response.result.ok) throw new Error('list failed') - expect(response.result.value.items).toEqual([ + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ expect.objectContaining({ sessionId: meta.id, blank: false, @@ -218,9 +223,8 @@ describe('attached updatedAt tracks human prompts', () => { it('ignores pickup and non-prompt work after the latest human message', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -241,25 +245,25 @@ describe('attached updatedAt tracks human prompts', () => { expect(boundary?.type).toBe('session/end-seed') expect(boundary?.time).toBeGreaterThan(worked) - const listed = await api.sessions.list(request({})) - if (!listed.result.ok) throw new Error('list failed') - const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched') + const listed = await remote.list(request({})) + if (!listed.ok) throw new Error('list failed') + const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched') expect(summary?.updatedAt).toBe(worked) // A lifecycle boundary is not a human update. resumed.append('turn/start', { turn: 2 }) - const afterBoundary = await api.sessions.list(request({})) - if (!afterBoundary.result.ok) throw new Error('list failed') - expect(afterBoundary.result.value.items.find(item => item.sessionId === 'resumed-untouched')?.updatedAt) + const afterBoundary = await remote.list(request({})) + if (!afterBoundary.ok) throw new Error('list failed') + expect(afterBoundary.value.items.find(item => item.sessionId === 'resumed-untouched')?.updatedAt) .toBe(worked) const prompt = resumed.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'new prompt' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const after = await api.sessions.list(request({})) - if (!after.result.ok) throw new Error('list failed') - const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched') + const after = await remote.list(request({})) + if (!after.ok) throw new Error('list failed') + const moved = after.value.items.find(item => item.sessionId === 'resumed-untouched') expect(moved?.updatedAt).toBe(prompt.time) }) }) @@ -268,7 +272,6 @@ describe('cold history recovery view', () => { it('shows in-memory interruption repair without activating the session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(UserQuestionService) const sessionId = sid('session-interrupted') const meta = header(sessionId, 1000) const stored: StoredPrefix = { @@ -292,11 +295,16 @@ describe('cold history recovery view', () => { inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), locate: () => undefined, } as never) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) - if (!history.result.ok) throw new Error('history failed') - expect(history.result.value.events.map(entry => entry.event)).toMatchInlineSnapshot(` + const history = await remote.page({ + address: { kind: 'session', sessionId }, + throughSeq: 1, + beforeSeq: 2, + maxMessages: 10, + }) + if (!history.ok) throw new Error('history failed') + expect(history.value.events.map(entry => entry.event)).toMatchInlineSnapshot(` [ { "data": { @@ -330,7 +338,6 @@ describe('Remote Agent and Session lookup policy', () => { await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const sessionId = sid('session-remote-cold') const meta = header(sessionId, 1000) const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] })) @@ -348,7 +355,7 @@ describe('Remote Agent and Session lookup policy', () => { }) const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -372,7 +379,6 @@ describe('Remote Agent and Session lookup policy', () => { await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const coldId = sid('session-remote-cold-child') const coldMeta = header(coldId, 1000, { parentSession: sid('session-parent'), @@ -392,7 +398,7 @@ describe('Remote Agent and Session lookup policy', () => { const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -423,7 +429,6 @@ describe('subagent ownership fence', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const sessionId = sid('session-child') const meta = header('session-child', 1000, { parentSession: sid('session-parent'), @@ -454,31 +459,36 @@ describe('subagent ownership fence', () => { locate: () => undefined, } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const history = await api.sessions.history(request({ sessionId })) - expect(history.result.ok).toBe(true) - if (history.result.ok) { - expect(history.result.value.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type)) - } + const history = await new SessionHistoryController(ctx).page({ + address: { + kind: 'subagent', + parentSessionId: meta.parentSession as SessionId, + childSessionId: sessionId, + mode: 'continuable', + }, + throughSeq: 3, + }, new AbortController().signal) + expect(history.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type)) expect(ctx.agents.get(sessionId)).toBeUndefined() - const prompt = await api.sessions.prompt(request({ + const prompt = await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'follow up' }], })) - expect(prompt.result.ok).toBe(false) - if (!prompt.result.ok) { - expect(prompt.result.error).toMatchObject({ + expect(prompt.ok).toBe(false) + if (!prompt.ok) { + expect(prompt.error).toMatchObject({ code: 'agent-busy', details: { reason: 'use subagent delivery for this child session' }, }) } - const create = await api.sessions.create(request({ sessionId, cwd: '/proj' })) - expect(create.result.ok).toBe(false) - if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy') + const create = await remote.create(request({ sessionId, cwd: '/proj' })) + expect(create.ok).toBe(false) + if (!create.ok) expect(create.error.code).toBe('agent-busy') expect(resume).not.toHaveBeenCalled() expect(ctx.agents.get(sessionId)).toBeUndefined() expect(inspect).toHaveBeenCalledTimes(3) @@ -488,7 +498,6 @@ describe('subagent ownership fence', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const sessionId = sid('session-legacy-child') const meta = header('session-legacy-child', 1000, { parentSession: sid('session-parent'), @@ -513,23 +522,22 @@ describe('subagent ownership fence', () => { // answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const prompt = await api.sessions.prompt(request({ + const prompt = await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'follow up' }], })) expect(resume).toHaveBeenCalledTimes(1) - expect(prompt.result.ok).toBe(false) - if (!prompt.result.ok) expect(prompt.result.error.code).toBe('internal') + expect(prompt.ok).toBe(false) + if (!prompt.ok) expect(prompt.error.code).toBe('internal') }) it('rejects origin-marked and runtime-owned live children from generic controls', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } }) const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent ctx.agents.register(parent) @@ -554,32 +562,30 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) - expect(stopped.result.ok).toBe(false) - if (!stopped.result.ok) expect(stopped.result.error.code).toBe('agent-busy') + const stopped = await remote.cancel(request({ sessionId: originChild.id })) + expect(stopped.ok).toBe(false) + if (!stopped.ok) expect(stopped.error.code).toBe('agent-busy') expect(cancel).not.toHaveBeenCalled() - const queued = await api.sessions.updateQueue(request({ + const queued = await remote.updateQueue(request({ sessionId: originChild.id, itemId: MessageId('queued-item'), action: { kind: 'remove' }, })) - expect(queued.result.ok).toBe(false) - if (!queued.result.ok) expect(queued.result.error.code).toBe('agent-busy') + expect(queued.ok).toBe(false) + if (!queued.ok) expect(queued.error.code).toBe('agent-busy') expect(updateInbox).not.toHaveBeenCalled() - const models = await api.sessions.models(request({ sessionId: startingChild.id })) - expect(models.result.ok).toBe(false) - if (!models.result.ok) expect(models.result.error.code).toBe('agent-busy') + const models = await remote.models(request({ sessionId: startingChild.id })) + expect(models.ok).toBe(false) + if (!models.ok) expect(models.error.code).toBe('agent-busy') - const create = await api.sessions.create(request({ sessionId: originChild.id, cwd: '/proj' })) - expect(create.result.ok).toBe(false) - if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy') + const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' })) + expect(create.ok).toBe(false) + if (!create.ok) expect(create.error.code).toBe('agent-busy') - const history = await api.sessions.history(request({ sessionId: originChild.id })) - expect(history.result.ok).toBe(true) expect(ctx.agents.get(originChild.id)).toBe(originChild) }) @@ -587,7 +593,6 @@ describe('subagent ownership fence', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const session = ctx.sessions.create(sid('session-ordinary-fork'), { seed: [{ type: 'subagent/descriptor', @@ -600,14 +605,14 @@ describe('subagent ownership fence', () => { const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const response = await api.sessions.prompt(request({ + const response = await remote.prompt(promptRequest({ sessionId: agent.id, mode: 'queue', content: [{ type: 'text', text: 'ordinary work' }], })) - expect(response.result.ok).toBe(true) + expect(response.ok).toBe(true) expect(followup).toHaveBeenCalledOnce() }) @@ -615,12 +620,11 @@ describe('subagent ownership fence', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -628,52 +632,46 @@ describe('subagent ownership fence', () => { const alias = 'US/Pacific' const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias }) .resolvedOptions().timeZone - const zonedRequest = request({ + const zonedRequest = promptRequest({ sessionId: agent.id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'zoned work' }], clientTimeZone: alias, }) - await expect(api.sessions.prompt(zonedRequest)).resolves.toMatchObject({ - result: { ok: true }, - }) + await expect(remote.prompt(zonedRequest)).resolves.toMatchObject({ ok: true }) expect(followup).toHaveBeenNthCalledWith(1, expect.objectContaining({ - source: { kind: 'user', rpcId: zonedRequest.rpcId, clientTimeZone: canonical }, + source: { kind: 'user', rpcId: zonedRequest.requestId, clientTimeZone: canonical }, })) - const utcRequest = request({ + const utcRequest = promptRequest({ sessionId: agent.id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'UTC work' }], clientTimeZone: 'UTC', }) - await expect(api.sessions.prompt(utcRequest)).resolves.toMatchObject({ - result: { ok: true }, - }) + await expect(remote.prompt(utcRequest)).resolves.toMatchObject({ ok: true }) expect(followup).toHaveBeenNthCalledWith(2, expect.objectContaining({ - source: { kind: 'user', rpcId: utcRequest.rpcId, clientTimeZone: 'UTC' }, + source: { kind: 'user', rpcId: utcRequest.requestId, clientTimeZone: 'UTC' }, })) - const unzonedRequest = request({ + const unzonedRequest = promptRequest({ sessionId: agent.id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'headless work' }], }) - await expect(api.sessions.prompt(unzonedRequest)).resolves.toMatchObject({ - result: { ok: true }, - }) + await expect(remote.prompt(unzonedRequest)).resolves.toMatchObject({ ok: true }) expect(followup).toHaveBeenNthCalledWith(3, expect.objectContaining({ - source: { kind: 'user', rpcId: unzonedRequest.rpcId }, + source: { kind: 'user', rpcId: unzonedRequest.requestId }, })) for (const clientTimeZone of ['', ' UTC', 'CST', 'Not/A_Real_Zone']) { - const invalid = await api.sessions.prompt(request({ + const invalid = await remote.prompt(promptRequest({ sessionId: agent.id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'invalid zone' }], clientTimeZone, })) - expect(invalid.result).toEqual({ + expect(invalid).toEqual({ ok: false, error: { code: 'invalid-time-zone', @@ -691,19 +689,21 @@ describe('degenerate composition (no persistence, no factory)', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const listed = await api.sessions.list(request({})) - expect(listed.result.ok).toBe(true) - if (listed.result.ok) expect(listed.result.value.items).toEqual([]) + const listed = await remote.list(request({})) + expect(listed.ok).toBe(true) + if (listed.ok) expect(listed.value.items).toEqual([]) // No persistence means cold history cannot inspect a transcript. - const response = await api.sessions.history(request({ sessionId: sid('session-ghost') })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) { - expect(response.result.error.code).toBe('internal') - expect(response.result.error.message).toMatch(/history unavailable for session "session-ghost"/) + const response = await remote.page({ + address: { kind: 'session', sessionId: sid('session-ghost') }, + throughSeq: -1, + }) + expect(response.ok).toBe(false) + if (!response.ok) { + expect(response.error.code).toBe('internal') + expect(response.error.message).toMatch(/session persistence is not configured/) } }) @@ -711,17 +711,19 @@ describe('degenerate composition (no persistence, no factory)', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const inspect = vi.fn() ctx.provide('sessionPersistence', { list: () => Promise.resolve([]), inspect, } as never) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') + const response = await remote.page({ + address: { kind: 'session', sessionId: sid('session-missing') }, + throughSeq: -1, + }) + expect(response.ok).toBe(false) + if (!response.ok) expect(response.error.code).toBe('session-not-found') expect(inspect).not.toHaveBeenCalled() }) }) @@ -731,7 +733,6 @@ describe('sessions.prompt synchronous rejection', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const session = ctx.sessions.create(sid('session-throwing')) // A live structural stub whose delivery verbs throw synchronously, the // shape a disposed loop presents at this gateway boundary. @@ -743,17 +744,17 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { - const response = await api.sessions.prompt(request({ + const response = await remote.prompt(promptRequest({ sessionId: session.id, mode, content: [{ type: 'text' as const, text: 'x' }], })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) { - expect(response.result.error.code).toBe('agent-busy') - expect(response.result.error.message).toBe('prompt rejected') - expect(response.result.error.details).toEqual({ + expect(response.ok).toBe(false) + if (!response.ok) { + expect(response.error.code).toBe('agent-busy') + expect(response.error.message).toBe('prompt rejected') + expect(response.error.details).toEqual({ reason: 'Error: agent "session-throwing" lifecycle disposed', }) } @@ -764,7 +765,6 @@ describe('sessions.prompt synchronous rejection', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) const sessionId = sid('race-resume') const meta: SessionHeader = header('race-resume', 1000) ctx.provide('sessionPersistence', { @@ -787,12 +787,12 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register(child) throw new Error('session id already published') }) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const models = await api.sessions.models(request({ sessionId })) - expect(models.result.ok).toBe(false) - if (!models.result.ok) { - expect(models.result.error).toMatchObject({ + const models = await remote.models(request({ sessionId })) + expect(models.ok).toBe(false) + if (!models.ok) { + expect(models.error).toMatchObject({ code: 'agent-busy', details: { reason: 'use subagent delivery for this child session' }, }) diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts similarity index 75% rename from packages/host/apiproxy/tests/api-proxy-fork.spec.ts rename to packages/api/session-controller/tests/session-fork.host.spec.ts index 33dede4562..481f51f825 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -1,4 +1,4 @@ -/** Session-fork boundaries, lineage, and inherited model routing. */ +/** Session Controller fork boundaries, lineage, and inherited model routing. */ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -9,17 +9,13 @@ import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import type { Workspace } from '@deepseek-ai/dsh-workspace' -import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import { createSessionTestRemote } from './test-remote.ts' const sid = (id: string): SessionId => id as SessionId -let nextRpc = 1 -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload } +function request

(payload: P): P { + return payload } async function composed(workspaces: readonly Workspace[] = []): Promise { @@ -27,7 +23,6 @@ async function composed(workspaces: readonly Workspace[] = []): Promise await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) ctx.provide('workspaceRegistry', { list: () => workspaces } as never) ctx.agents.setFactory({ createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise => { @@ -81,7 +76,7 @@ function liveAgent( return session } -const api = (ctx: Context) => createApiProxy(ctx, { +const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }), cwd: '/tmp', }) @@ -90,10 +85,10 @@ describe('sessions.fork', () => { it('cuts at the anchored completed turn and records lineage and cwd', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-source', 2) - const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: 1 })) - expect(response.result.ok).toBe(true) - if (!response.result.ok) return - const child = ctx.sessions.get(response.result.value.sessionId) + const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 1 })) + expect(response.ok).toBe(true) + if (!response.ok) return + const child = ctx.sessions.get(response.value.sessionId) expect(child?.events.map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'session/end-seed', ]) @@ -134,16 +129,16 @@ describe('sessions.fork', () => { })), } as never) - const response = await api(ctx).sessions.fork(request({ sessionId: grandchild.id })) + const response = await remote(ctx).fork(request({ sessionId: grandchild.id })) - expect(response.result.ok).toBe(true) - if (!response.result.ok) return - expect(attachSession).toHaveBeenCalledWith(response.result.value.sessionId) - expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({ + expect(response.ok).toBe(true) + if (!response.ok) return + expect(attachSession).toHaveBeenCalledWith(response.value.sessionId) + expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({ parentSession: grandchild.id, cwd: '/proj', }) - expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined() + expect(ctx.sessions.get(response.value.sessionId)?.header.origin).toBeUndefined() await ctx.fiber.dispose() }) @@ -185,54 +180,66 @@ describe('sessions.fork', () => { } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const response = await api(ctx).sessions.fork(request({ sessionId: sourceId })) + const response = await remote(ctx).fork(request({ sessionId: sourceId })) - expect(response.result.ok).toBe(true) - if (!response.result.ok) return + expect(response.ok).toBe(true) + if (!response.ok) return expect(resume).not.toHaveBeenCalled() expect(ctx.agents.get(sourceId)).toBeUndefined() - expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({ + expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({ parentSession: sourceId, cwd: '/proj', }) - expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined() + expect(ctx.sessions.get(response.value.sessionId)?.header.origin).toBeUndefined() await ctx.fiber.dispose() }) it('uses the last completed turn only for omitted and past-end anchors', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-tail', 2, 'open') - const proxy = api(ctx) + const proxy = remote(ctx) const expectedTypes = [ 'turn/start', 'user/message', 'turn/end', 'turn/start', 'user/message', 'turn/end', 'session/end-seed', ] - const omitted = await proxy.sessions.fork(request({ sessionId: source.id })) - expect(omitted.result.ok).toBe(true) - if (omitted.result.ok) { - expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.map(event => event.type)) + const omitted = await proxy.fork(request({ sessionId: source.id })) + expect(omitted.ok).toBe(true) + if (omitted.ok) { + expect(ctx.sessions.get(omitted.value.sessionId)?.events.map(event => event.type)) .toEqual(expectedTypes) } - const pastEnd = await proxy.sessions.fork(request({ sessionId: source.id, atSeq: 999 })) - expect(pastEnd.result.ok).toBe(true) - if (pastEnd.result.ok) { - expect(ctx.sessions.get(pastEnd.result.value.sessionId)?.events.map(event => event.type)) + const pastEnd = await proxy.fork(request({ sessionId: source.id, atSeq: 999 })) + expect(pastEnd.ok).toBe(true) + if (pastEnd.ok) { + expect(ctx.sessions.get(pastEnd.value.sessionId)?.events.map(event => event.type)) .toEqual(expectedTypes) } await ctx.fiber.dispose() }) + it('rejects invalid fork anchors before reading or creating a Session', async () => { + const ctx = await composed() + const proxy = remote(ctx) + + for (const atSeq of [-1, 0.5]) { + await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq }))) + .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + } + expect(ctx.sessions.list()).toEqual([]) + await ctx.fiber.dispose() + }) + it('cuts through an aborted turn: stopped is closed, not open', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-aborted', 1, 'aborted') // What a stopped message's fork button anchors on: the frozen node sits // one event before its turn/end, floored client-side to that event's seq. const anchor = (source.events.at(-1)?.seq ?? 0) - 1 - const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor })) - expect(response.result.ok).toBe(true) - if (!response.result.ok) return - expect(ctx.sessions.get(response.result.value.sessionId)?.events.map(event => event.type)).toEqual([ + const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) + expect(response.ok).toBe(true) + if (!response.ok) return + expect(ctx.sessions.get(response.value.sessionId)?.events.map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'turn/start', 'user/message', 'turn/end', 'session/end-seed', @@ -244,12 +251,12 @@ describe('sessions.fork', () => { const ctx = await composed() const source = liveAgent(ctx, 'session-open', 1, 'open') const anchor = source.events.at(-1)?.seq ?? 0 - const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor })) - expect(response.result).toMatchObject({ + const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) + expect(response).toMatchObject({ ok: false, error: { code: 'fork-unavailable', details: { sessionId: source.id } }, }) - if (!response.result.ok) expect(response.result.error.message).toMatch(/has not completed/) + if (!response.ok) expect(response.error.message).toMatch(/has not completed/) await ctx.fiber.dispose() }) @@ -266,10 +273,10 @@ describe('sessions.fork', () => { }, reason: 'initial', }) - const response = await api(ctx).sessions.fork(request({ sessionId: source.id })) - expect(response.result.ok).toBe(true) - if (!response.result.ok) return - const child = ctx.agents.get(response.result.value.sessionId) + const response = await remote(ctx).fork(request({ sessionId: source.id })) + expect(response.ok).toBe(true) + if (!response.ok) return + const child = ctx.agents.get(response.value.sessionId) if (child === undefined) throw new Error('fork did not publish the child agent') const assembly = await child.ctx.systemPrompt.assemble() expect(assembly.variables).toMatchObject({ diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/api/session-controller/tests/session-history-view.host.spec.ts similarity index 73% rename from packages/host/apiproxy/tests/api-proxy-view.spec.ts rename to packages/api/session-controller/tests/session-history-view.host.spec.ts index 6955b9416c..9de6a00985 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/api/session-controller/tests/session-history-view.host.spec.ts @@ -1,10 +1,9 @@ /** - * Tool-card view computation over the mux live path: three standard card types + * Tool-card view computation over Session Controller history and follow: three standard card types * arrive on the frame, a presenterless tool ships no view field, a call-only * presenter keeps raw result content out of the view payload, and a throwing * presenter soft-falls to no view (the event still ships). Result pairing - * works both through the live open-call table and the backscan fallback after - * turn/end cleared it. + * works for both paged and live entries. */ import { describe, expect, it, vi } from 'vitest' @@ -18,10 +17,9 @@ import { CallId, createMessage, createToolResultMessage, createUserMessage } fro import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' -import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' +import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import { createSessionTestRemote } from './test-remote.ts' const reply = (text: string): Promise => Promise.resolve([{ type: 'text', text }]) @@ -69,7 +67,6 @@ async function harness(): Promise<{ ctx: Context }> { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) - await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) ctx.tools.register(tool('gen', { presentCall: () => ({ card: 'generic', title: 'gen call' }), @@ -92,26 +89,46 @@ async function harness(): Promise<{ ctx: Context }> { return { ctx } } -/** Drain frames from an open mux stream until `count` session/event frames arrived. */ -async function collect(iterable: AsyncIterable>, count: number, abort: AbortController): Promise { - const frames: MuxFrame[] = [] +/** Drain one Session follow until `count` event frames arrive. */ +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: SessionFollowFrame[] = [] for await (const frame of iterable) { - frames.push(frame.payload) - if (frames.filter(f => f.type === 'session/event').length >= count) abort.abort() + frames.push(frame) + if (frames.filter(candidate => candidate.type === 'event').length >= count) abort.abort() } return frames } -describe('mux live view computation', () => { +/** Open follow and wait until its cursor is fixed before appending fixtures. */ +async function openFollow( + history: SessionHistoryController, + sessionId: SessionId, + signal: AbortSignal, +): Promise> { + const iterator = history.follow({ + address: { kind: 'session', sessionId }, + }, signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'opened' }, + }) + return { [Symbol.asyncIterator]: () => iterator } +} + +describe('Session history view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create() + const history = new SessionHistoryController(ctx) const abort = new AbortController() - const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) + const stream = await openFollow(history, session.id, abort.signal) const collected = collect(stream, 9, abort) const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}` - const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' }) @@ -137,13 +154,13 @@ describe('mux live view computation', () => { }, { surfaceOp: 'append' }) const frames = await collected - const events = frames.filter(f => f.type === 'session/event') + const events = frames.filter(f => f.type === 'event') const byCall = new Map(events .filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result') .map(f => [ `${f.event.type}:${f.event.type === 'tool/call' - ? f.event.data.callId - : (f.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`, + ? (f.event.data as unknown as SessionEvent<'tool/call'>['data']).callId + : (f.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`, f, ])) @@ -168,9 +185,47 @@ describe('mux live view computation', () => { expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } }) }) + it('pairs live results from the open-call table without rescanning Session history', async () => { + const { ctx } = await harness() + const session = ctx.sessions.create() + const history = new SessionHistoryController(ctx) + const abort = new AbortController() + const stream = await openFollow(history, session.id, abort.signal) + const iterator = stream[Symbol.asyncIterator]() + + session.append('tool/call', { + turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}', + }) + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'event', view: { for: 'call', view: { card: 'terminal', title: 'pwd' } } }, + }) + + const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + throw new Error('live result rescanned Session history') + }) + try { + session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: CallId('live-fast'), + content: [{ type: 'text', text: 'ok' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'event', view: { for: 'result', view: { card: 'terminal', output: 'done' } } }, + }) + } finally { + events.mockRestore() + abort.abort() + await iterator.next() + await ctx.fiber.dispose() + } + }) + it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -217,16 +272,19 @@ describe('mux live view computation', () => { }), }, { surfaceOp: 'append' }) - const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } }) - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - const entries = response.result.value.events + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + }) + expect(response.ok).toBe(true) + if (!response.ok) throw new Error('unreachable') + const entries = response.value.events const byKey = new Map(entries .filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result') .map(entry => [ `${entry.event.type}:${entry.event.type === 'tool/call' - ? entry.event.data.callId - : (entry.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`, + ? (entry.event.data as unknown as SessionEvent<'tool/call'>['data']).callId + : (entry.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`, entry, ])) expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } }) @@ -238,7 +296,7 @@ describe('mux live view computation', () => { it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -265,18 +323,19 @@ describe('mux live view computation', () => { sourceEventSeqs: [...shadowed, summary.seq], }) - const response = await api.sessions.history({ - rpcId: RpcId('t-hist-compact'), - payload: { sessionId: session.id, maxMessages: 2 }, + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + maxMessages: 2, }) - if (!response.result.ok) throw new Error('unreachable') - const page = response.result.value.events.map(entry => entry.event) + if (!response.ok) throw new Error('unreachable') + const page = response.value.events.map(entry => entry.event) // Two append-origin messages fill the page even though a replacement copy of // the same event type sits in the window: the copy is model-only. const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message') expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3]) expect(page.some(event => event.seq === first.seq)).toBe(false) - expect(response.result.value.hasMore).toBe(true) + expect(response.value.hasMore).toBe(true) // The range stays contiguous, so the checkpoint's summary record is readable on // the same page as the checkpoint itself. const summaryIndex = page.findIndex(event => event.seq === summary.seq) @@ -287,7 +346,7 @@ describe('mux live view computation', () => { it('paginates a message with many provenance sources without variadic argument expansion', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -312,52 +371,30 @@ describe('mux live view computation', () => { return scalarMin(...values) }) try { - const response = await api.sessions.history({ - rpcId: RpcId('t-hist-large-provenance'), - payload: { sessionId: session.id, maxMessages: 1 }, + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: message.seq, + maxMessages: 1, }) - if (!response.result.ok) throw new Error('unreachable') - expect(response.result.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq]) - expect(response.result.value.hasMore).toBe(true) + if (!response.ok) throw new Error('unreachable') + expect(response.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq]) + expect(response.value.hasMore).toBe(true) } finally { min.mockRestore() } }) - it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { + it('pairs a followed result after turn/end from the addressed Session log', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create() + const history = new SessionHistoryController(ctx) const abort = new AbortController() - const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) - - let session: Session | undefined - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create('session-doomed' as SessionId) - }, { inject: ['sessions'] })) - session?.append('turn/start', { turn: 1 }) - session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' }) - // Disposing the owning fiber detaches the session mid-stream; the - // session/disposed listener must clear its open-call table entry. - await fiber.dispose() - - const frames = await collect(stream, 2, abort) - const call = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/call') - expect(call?.type === 'session/event' && call.view?.for).toBe('call') - }) - - it('pairs a result after turn/end via the in-memory backscan fallback', async () => { - const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - const abort = new AbortController() - const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) + const stream = await openFollow(history, session.id, abort.signal) const collected = collect(stream, 4, abort) - const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - // The turn/end above cleared the live table; pairing must fall back to - // scanning the session's in-memory events. session.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ @@ -368,7 +405,7 @@ describe('mux live view computation', () => { }, { surfaceOp: 'append' }) const frames = await collected - const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result') - expect(result?.type === 'session/event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } }) + const result = frames.find(f => f.type === 'event' && f.event.type === 'tool/result') + expect(result?.type === 'event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } }) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts similarity index 61% rename from packages/host/apiproxy/tests/api-proxy-blank.spec.ts rename to packages/api/session-controller/tests/session-list-blank.host.spec.ts index 1ee3a5100f..cdfbbda39b 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts @@ -13,29 +13,19 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import { CommandId } from '@deepseek-ai/dsh-commands/brand' -// Side-effect type imports: the knob-event SessionEventMap merges. +// Side-effect type imports: the configuration-event SessionEventMap merges. import type {} from '@deepseek-ai/dsh-permission-presets' import type {} from '@deepseek-ai/dsh-sandbox-policy' -import type {} from '@deepseek-ai/dsh-user-approval' -import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' -let nextRpc = 1 -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`blank-${String(nextRpc++)}`), payload } -} - -async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (session: Session) => void }> { +async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => void }> { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) return { ctx, - api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), + remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), attach: (session) => { ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, @@ -52,34 +42,33 @@ function appendStandalone(session: Session): void { session.append('session/title', { title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' }, }) - // The three permission knob events (a /permission switch on a fresh session). + // Permission configuration events from a /permission switch on a fresh session. session.append('permission/preset', { preset: 'danger-full-access' }) session.append('sandbox/mode', { mode: 'danger-full-access' }) - session.append('approval/policy', { policy: 'never' }) } -async function listBlank(api: ApiProxy, id: string): Promise { - const response = await api.sessions.list(request({})) - if (!response.result.ok) throw new Error('list failed') - return response.result.value.items.find(item => item.sessionId === id)?.blank +async function listBlank(remote: TestSessionRemote, id: string): Promise { + const result = await remote.list({}) + if (!result.ok) throw new Error('list failed') + return result.value.items.find(item => item.sessionId === id)?.blank } describe('summary blank = conversation not started', () => { it('standalone events (command lifecycle, plan/mode, title) keep the session blank', async () => { - const { ctx, api, attach } = await harness() + const { ctx, remote, attach } = await harness() const session = ctx.sessions.create() attach(session) - expect(await listBlank(api, session.id)).toBe(true) + expect(await listBlank(remote, session.id)).toBe(true) appendStandalone(session) - expect(await listBlank(api, session.id)).toBe(true) + expect(await listBlank(remote, session.id)).toBe(true) }) it('the first turn clears blank', async () => { - const { ctx, api, attach } = await harness() + const { ctx, remote, attach } = await harness() const session = ctx.sessions.create() attach(session) appendStandalone(session) session.append('turn/start', { turn: 0 }) - expect(await listBlank(api, session.id)).toBe(false) + expect(await listBlank(remote, session.id)).toBe(false) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts similarity index 63% rename from packages/host/apiproxy/tests/api-proxy-models.spec.ts rename to packages/api/session-controller/tests/session-models.host.spec.ts index 1317220ef3..e67c414c44 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -1,5 +1,5 @@ /** - * Web session model-directory and selection behavior: dynamic provider grouping, + * Session Controller model-directory and selection behavior: dynamic provider grouping, * provider-local catalog failures, logged-selection restoration without stale * catalog injection, advisory pass-through models, and the prompt-assembly * boundary for a running selection change. @@ -18,15 +18,23 @@ import type { } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' -import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '../src/api-proxy.ts' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { createSessionTestRemote } from './test-remote.ts' -let nextRpc = 1 -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload } +function request

(payload: P): P { + return payload +} + +let nextRequestId = 1 +function promptRequest( + payload: Omit, +): SessionPromptRequest { + return { + ...payload, + requestId: `models-${String(nextRequestId++)}` as SessionRequestId, + } } class CatalogAdapter extends LlmAdapter { @@ -86,7 +94,6 @@ async function harness(logged?: { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(LlmRuntime) - await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [ { provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' }, @@ -96,6 +103,16 @@ async function harness(logged?: { ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [ { provider: 'metadata-broken', id: 'listed', name: 'Listed' }, ], undefined, new Error('reasoning metadata offline'))) + ctx.llm.registerAdapter(['remote-rejected'], new CatalogAdapter( + 'Remote Rejected', + [], + undefined, + new TypertRemoteFailure({ + code: 'fixture-rejected', + message: 'fixture rejected the selection', + details: { provider: 'remote-rejected' }, + }), + )) ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', [])) ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [ { provider: 'duplicate', id: 'same', name: 'Same' }, @@ -116,9 +133,9 @@ async function harness(logged?: { return { ctx, agent, sessionId: session.id } } -function expectValue(response: { result: { ok: true; value: T } | { ok: false } }): T { - if (!response.result.ok) throw new Error('expected successful response') - return response.result.value +function expectValue(result: { ok: true; value: T } | { ok: false }): T { + if (!result.ok) throw new Error('expected successful response') + return result.value } function registerTextOnly(ctx: Context): void { @@ -156,12 +173,12 @@ describe('Web session model selection', () => { ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) - const result = await api.sessions.prompt(request({ + const result = await remote.prompt(promptRequest({ sessionId, mode: 'queue' as const, content: [ @@ -170,7 +187,7 @@ describe('Web session model selection', () => { { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' }, ], })) - expect(result.result.ok).toBe(true) + expect(result.ok).toBe(true) expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ @@ -184,14 +201,14 @@ describe('Web session model selection', () => { { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, ]) - const denied = await api.sessions.prompt(request({ + const denied = await remote.prompt(promptRequest({ sessionId, mode: 'queue' as const, content: Array.from({ length: 3 }, () => ({ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', })), })) - expect(denied.result).toMatchObject({ + expect(denied).toMatchObject({ ok: false, error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } }, }) @@ -202,7 +219,7 @@ describe('Web session model selection', () => { it('allows a text-only selection while durable or pending images remain available for later models', async () => { const { ctx, agent, sessionId } = await harness() registerTextOnly(ctx) - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -213,7 +230,7 @@ describe('Web session model selection', () => { agent.session.append('user/message', { id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image], } as never, { surfaceOp: 'append' }) - expect(expectValue(await api.sessions.selectModel(request({ + expect(expectValue(await remote.selectModel(request({ sessionId, provider: 'text-only', model: 'plain', }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) @@ -227,7 +244,7 @@ describe('Web session model selection', () => { ;(agent.inbox.nextTurn as UserMessage[]).push({ id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image], } as never) - expect(expectValue(await api.sessions.selectModel(request({ + expect(expectValue(await remote.selectModel(request({ sessionId, provider: 'text-only', model: 'plain', }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) await ctx.fiber.dispose() @@ -240,7 +257,7 @@ describe('Web session model selection', () => { } const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) ctx.provide('attachments', { readImage } as never) - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -253,14 +270,14 @@ describe('Web session model selection', () => { }], } as never) - const allowed = await api.sessions.attachment(request({ + const allowed = await remote.attachment(request({ sessionId, attachmentId: 'att-authorized' as never, })) - expect(allowed.result).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } }) - const denied = await api.sessions.attachment(request({ + expect(allowed).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } }) + const denied = await remote.attachment(request({ sessionId, attachmentId: 'att-other' as never, })) - expect(denied.result).toMatchObject({ + expect(denied).toMatchObject({ ok: false, error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, }) @@ -273,9 +290,9 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) - const catalog = expectValue(await api.sessions.models(request({ sessionId }))) + const catalog = expectValue(await remote.models(request({ sessionId }))) expect(catalog.current).toEqual({ provider: 'deepseek-official', model: 'private-preview', @@ -306,18 +323,60 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('preserves optional catalog metadata and string provider failures', async () => { + const { ctx, sessionId } = await harness() + ctx.llm.registerAdapter(['plain'], new CatalogAdapter('Plain', [ + { provider: 'plain', id: 'plain-model', name: 'Plain Model' }, + ])) + ctx.llm.registerAdapter(['described-reasoning'], new CatalogAdapter('Described Reasoning', [ + { provider: 'described-reasoning', id: 'reasoning-model', name: 'Reasoning Model' }, + ], { + efforts: [{ id: ReasoningEffortId('high'), name: 'High', description: 'More thinking' }], + })) + ctx.llm.registerAdapter(['string-failure'], new class extends CatalogAdapter { + override listModels(): Promise { + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario. + return Promise.reject('string catalog failure') + } + }('String Failure', [])) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + + const catalog = expectValue(await remote.models(request({ sessionId }))) + expect(catalog.groups).toEqual(expect.arrayContaining([ + { id: 'plain', name: 'Plain', models: [{ id: 'plain-model', name: 'Plain Model' }] }, + { + id: 'described-reasoning', + name: 'Described Reasoning', + models: [{ + id: 'reasoning-model', + name: 'Reasoning Model', + reasoning: { + efforts: [{ id: 'high', name: 'High', description: 'More thinking' }], + }, + }], + }, + ])) + expect(catalog.failures).toContainEqual({ + id: 'string-failure', name: 'String Failure', message: 'string catalog failure', + }) + await ctx.fiber.dispose() + }) + it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal - expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + expect(expectValue(await remote.models(request({ sessionId }))).current) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) expect((await ctx.systemPrompt.assemble()).variables) .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' }) - const selected = expectValue(await api.sessions.selectModel(request({ + const selected = expectValue(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'private-preview', @@ -342,13 +401,13 @@ describe('Web session model selection', () => { reasoningEffort: 'max', }) - const unsupported = await api.sessions.selectModel(request({ + const unsupported = await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'medium', })) - expect(unsupported.result).toMatchObject({ + expect(unsupported).toMatchObject({ ok: false, error: { code: 'model-unavailable', @@ -356,12 +415,12 @@ describe('Web session model selection', () => { }, }) - const rejected = await api.sessions.selectModel(request({ + const rejected = await remote.selectModel(request({ sessionId, provider: 'missing', model: 'model', })) - expect(rejected.result).toEqual({ + expect(rejected).toEqual({ ok: false, error: { code: 'model-unavailable', @@ -369,7 +428,19 @@ describe('Web session model selection', () => { details: { provider: 'missing', model: 'model' }, }, }) - expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + expect(await remote.selectModel(request({ + sessionId, + provider: 'remote-rejected', + model: 'model', + }))).toEqual({ + ok: false, + error: { + code: 'fixture-rejected', + message: 'fixture rejected the selection', + details: { provider: 'remote-rejected' }, + }, + }) + expect(expectValue(await remote.models(request({ sessionId }))).current) .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) @@ -377,21 +448,19 @@ describe('Web session model selection', () => { it('reads the Agent default live for a session whose log names no selection', async () => { const { ctx, sessionId } = await harness() let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) - expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + expect(expectValue(await remote.models(request({ sessionId }))).current) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) // The default moving after the session exists still reaches it: New // Session reuses a blank session rather than minting another, so a seed // captured at creation would show the superseded model there. stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' } - expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + expect(expectValue(await remote.models(request({ sessionId }))).current) .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) - expect(expectValue(await api.host.describe(request({})))) - .toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) await ctx.fiber.dispose() }) @@ -401,13 +470,13 @@ describe('Web session model selection', () => { model: 'deepseek-chat', }) let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) stored = { provider: 'duplicate', model: 'same' } - expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + expect(expectValue(await remote.models(request({ sessionId }))).current) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) await ctx.fiber.dispose() }) @@ -416,7 +485,7 @@ describe('Web session model selection', () => { const { ctx, sessionId } = await harness() const saved: unknown[] = [] let reject = false - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), saveDefaultModelSelection: (selection) => { saved.push(selection) @@ -425,7 +494,7 @@ describe('Web session model selection', () => { cwd: '/tmp', }) - expectValue(await api.sessions.selectModel(request({ + expectValue(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max', }))) expect(saved).toEqual([ @@ -433,45 +502,45 @@ describe('Web session model selection', () => { ]) // A refused selection never becomes anyone's default. - await api.sessions.selectModel(request({ sessionId, provider: 'missing', model: 'model' })) + await remote.selectModel(request({ sessionId, provider: 'missing', model: 'model' })) expect(saved).toHaveLength(1) // Storage failing is not the selection failing: the switch already applies // to this session, so the call still succeeds. reject = true - const stillAccepted = expectValue(await api.sessions.selectModel(request({ + const stillAccepted = expectValue(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'deepseek-chat', }))) expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) - expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + expect(expectValue(await remote.models(request({ sessionId }))).current) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) await ctx.fiber.dispose() }) it('refuses a prompt no adapter can route, and reports it on the directory', async () => { const { ctx, sessionId } = await harness() - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', }) // The client disabling its input is an affordance; this method stays // callable, so the refusal has to live here. - const refused = await api.sessions.prompt(request({ + const refused = await remote.prompt(promptRequest({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }], })) - expect(refused.result).toMatchObject({ + expect(refused).toMatchObject({ ok: false, error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, }) - expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false) + expect(expectValue(await remote.models(request({ sessionId }))).routable).toBe(false) // An advisory-unlisted model on a live route is NOT this: the route // serves it, so the prompt goes through and nothing blocks. - expectValue(await api.sessions.selectModel(request({ + expectValue(await remote.selectModel(request({ sessionId, provider: 'deepseek-official', model: 'unlisted-but-served', }))) - const catalog = expectValue(await api.sessions.models(request({ sessionId }))) + const catalog = expectValue(await remote.models(request({ sessionId }))) expect(catalog.routable).toBe(true) expect(catalog.groups.flatMap(group => group.models.map(model => model.id))) .not.toContain('unlisted-but-served') @@ -480,14 +549,14 @@ describe('Web session model selection', () => { it('serves a session and its catalog when the stored default names a route that is gone', async () => { const { ctx, sessionId } = await harness() - const api = createApiProxy(ctx, { + const remote = createSessionTestRemote(ctx, { // What a Models-page removal leaves behind: the settings document still // names the route the user last picked, and nothing serves it. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', }) - const catalog = expectValue(await api.sessions.models(request({ sessionId }))) + const catalog = expectValue(await remote.models(request({ sessionId }))) // Passed through rather than repaired: matching no group is precisely what // makes the composer seat prompt for a selection instead of naming a model // the deployment cannot reach. @@ -496,4 +565,99 @@ describe('Web session model selection', () => { .not.toContain('deleted-gateway/deleted-model') await ctx.fiber.dispose() }) + + it('maps image admission failures and accepts image-capable selections', async () => { + const { ctx, agent, sessionId } = await harness() + registerTextOnly(ctx) + ctx.llm.registerAdapter(['image-capable'], new class extends CatalogAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, id: model, name: model, inputModalities: ['text', 'image'], + }) + } + }('Image Capable', [])) + ctx.llm.registerAdapter(['string-error'], new class extends CatalogAdapter { + override resolveModel(): Promise { + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario. + return Promise.reject('string selection failure') + } + }('String Error', [])) + let saveMode: 'success' | 'error' | 'remote' = 'success' + const savedRef = { + attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, + } + ctx.provide('attachments', { + saveImages: () => { + if (saveMode === 'error') return Promise.reject(new Error('image store offline')) + if (saveMode === 'remote') { + return Promise.reject(new TypertRemoteFailure({ + code: 'fixture-rejected', message: 'fixture rejected', details: {}, + })) + } + return Promise.resolve([savedRef]) + }, + } as never) + const followup = vi.fn() + Object.assign(agent, { followup }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==' } + + expectValue(await remote.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))) + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [image], + }))).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, + }) + + expectValue(await remote.selectModel(request({ + sessionId, provider: 'image-capable', model: 'vision', + }))) + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [{ ...image, data: '' }], + }))).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'INVALID_IMAGE_BASE64' } }, + }) + + saveMode = 'error' + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [image], + }))).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + saveMode = 'remote' + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [image], + }))).toMatchObject({ ok: false, error: { code: 'fixture-rejected' } }) + saveMode = 'success' + expectValue(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image] }))) + expect(followup).toHaveBeenCalledOnce() + + ;(agent.inbox.nextTurn as UserMessage[]).push({ + id: 'pending-image', role: 'user', source: { kind: 'user' }, + content: [{ type: 'image', attachment: savedRef }], + } as never) + expectValue(await remote.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'deepseek-chat', + }))) + expectValue(await remote.selectModel(request({ + sessionId, provider: 'image-capable', model: 'vision', + }))) + expect(await remote.selectModel(request({ + sessionId, provider: 'metadata-broken', model: 'broken', + }))).toMatchObject({ + ok: false, error: { code: 'model-unavailable', message: 'reasoning metadata offline' }, + }) + expect(await remote.selectModel(request({ + sessionId, provider: 'string-error', model: 'broken', + }))).toMatchObject({ + ok: false, + error: { code: 'model-unavailable', message: 'string selection failure' }, + }) + await ctx.fiber.dispose() + }) }) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts new file mode 100644 index 0000000000..87d4e038e1 --- /dev/null +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -0,0 +1,165 @@ +/** Session creation and adoption rules for Agent preset identity. */ + +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' +import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import { describe, expect, it } from 'vitest' +import { createSessionTestRemote } from './test-remote.ts' + +function stubAgent(session: Session): Agent { + return { id: session.id, session, status: 'idle' } as unknown as Agent +} + +function roster(ids: readonly string[]): unknown { + const presetOf = (id: string): object => ({ + id, + trust: 'system', + path: `/presets/${id}/agent.cordis.yml`, + }) + return { + defaultId: ids[0], + resolve: (id?: string) => { + const wanted = id ?? ids[0] ?? '' + if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) + return Promise.resolve(presetOf(wanted)) + }, + mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), + } +} + +async function harness(presets?: readonly string[]) { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-'))) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) + if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never) + + const factory: AgentFactory = { + async createAgent(_ownerCtx, options) { + const session = ctx.sessions.create( + options.sessionId, + options.meta === undefined ? {} : { meta: options.meta }, + ) + const agent = stubAgent(session) + const agentCtx = ctx.extend({ agent }) + ;(agent as { ctx?: Context }).ctx = agentCtx + await options.setup?.(agentCtx) + const unregister = ctx.agents.register(agent) + return { agent, dispose: () => { unregister(); return Promise.resolve() } } + }, + async resume() { + throw new Error('test harness has no persisted sessions') + }, + } + ctx.agents.setFactory(factory) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), + cwd, + }) + return { ctx, remote } +} + +describe('session.create Agent preset identity', () => { + it('records the requested preset on the Session header', async () => { + const { ctx, remote } = await harness(['standard', 'minimal']) + + const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' }) + + expect(created.ok).toBe(true) + expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal') + }) + + it('records the roster default when the caller names no preset', async () => { + const { ctx, remote } = await harness(['standard', 'minimal']) + + await remote.create({ sessionId: SessionId('s2') }) + + expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard') + }) + + it('rejects an unknown preset', async () => { + const { remote } = await harness(['standard']) + + const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset-not-found' } }) + }) + + it('refuses to adopt a live Session under a different preset', async () => { + const { remote } = await harness(['standard', 'minimal']) + await remote.create({ sessionId: SessionId('s4'), agentPreset: 'minimal' }) + + const response = await remote.create({ sessionId: SessionId('s4'), agentPreset: 'standard' }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'agent-preset-conflict', + details: { + sessionId: 's4', + requestedPreset: 'standard', + existingPreset: 'minimal', + }, + }, + }) + }) + + it('adopts a live Session under the preset selected in its log', async () => { + const { ctx, remote } = await harness(['standard', 'minimal']) + await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) + ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const adopted = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }) + const stale = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) + + expect(adopted).toMatchObject({ ok: true, value: { agentPreset: 'minimal' } }) + expect(stale).toMatchObject({ + ok: false, + error: { details: { existingPreset: 'minimal' } }, + }) + }) + + it('adopts a live Session unchanged when the caller names no preset', async () => { + const { remote } = await harness(['standard', 'minimal']) + await remote.create({ sessionId: SessionId('s5'), agentPreset: 'minimal' }) + + await expect(remote.create({ sessionId: SessionId('s5') })) + .resolves.toMatchObject({ ok: true }) + }) + + it('leaves the header preset-less when no roster is composed', async () => { + const { ctx, remote } = await harness() + + await remote.create({ sessionId: SessionId('s6') }) + + expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() + }) + + it('explains why a preset-less Session cannot be adopted under one', async () => { + const { remote } = await harness() + await remote.create({ sessionId: SessionId('s7') }) + + const response = await remote.create({ sessionId: SessionId('s7'), agentPreset: 'standard' }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'agent-preset-conflict', + details: { + sessionId: 's7', + requestedPreset: 'standard', + }, + }, + }) + if (response.ok) throw new Error('unreachable') + expect('existingPreset' in response.error.details).toBe(false) + expect(response.error.message).toContain('records no agent preset') + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts similarity index 53% rename from packages/host/apiproxy/tests/api-proxy-projections.spec.ts rename to packages/api/session-controller/tests/session-projections.host.spec.ts index 6d9c53a6f8..dfd55d6a52 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -1,10 +1,10 @@ /** - * Projection carrier paths of the host ApiProxy: the history tail page's + * Session Controller projection paths: the history tail page's * projections block reads the registry's watermark snapshot (asOfSeq = last * event seq, one consistent cut); loadOlder pages never carry the block; a * composition without the registry serves histories without it; a disposed * registration's key leaves subsequent responses; and every unit change is - * pushed to mux consumers as a session/projection frame minted here. + * pushed through the control stream. */ import { describe, expect, it, vi } from 'vitest' @@ -18,10 +18,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' -import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts' +import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { @@ -33,9 +32,20 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -let nextRpc = 1 -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`proj-${String(nextRpc++)}`), payload } +function request

(payload: P): P { + return payload +} + +function page( + remote: TestSessionRemote, + request: { sessionId: SessionId; throughSeq: number; beforeSeq?: number; maxMessages?: number }, +) { + return remote.page({ + address: { kind: 'session', sessionId: request.sessionId }, + throughSeq: request.throughSeq, + ...(request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }), + ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }), + }) } /** Whole-value unit folding the latest user/message text; null before the first. */ @@ -65,7 +75,6 @@ const internalCountUnit = () => ({ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) if (withRegistry) await ctx.plugin(SessionProjectionRegistry) const session = ctx.sessions.create() @@ -84,17 +93,17 @@ function seedMessages(session: Session, count: number): void { } } -const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) +const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 3) - const response = await api(ctx).sessions.history(request({ sessionId: session.id })) - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - const { events, projections } = response.result.value + const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) + expect(response.ok).toBe(true) + if (!response.ok) throw new Error('unreachable') + const { events, projections } = response.value expect(projections).toBeDefined() expect(projections?.asOfSeq).toBe(session.seq - 1) expect(projections?.values['test/last-user']).toEqual({ text: 'm2' }) @@ -102,6 +111,35 @@ describe('session.history projections block', () => { expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq) }) + it('cuts attached projections and events at the requested follow cursor', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + seedMessages(session, 2) + + const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: 0 })) + if (!response.ok) throw new Error('history failed') + + expect(response.value.events.map(entry => entry.event.seq)).toEqual([0]) + expect(response.value.projections?.asOfSeq).toBe(0) + expect(response.value.projections?.values).toEqual( + expect.objectContaining({ 'test/last-user': { text: 'm0' } }), + ) + }) + + it('projects an empty log at cursor -1', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + + const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: -1 })) + if (!response.ok) throw new Error('history failed') + + expect(response.value.events).toEqual([]) + expect(response.value.projections?.asOfSeq).toBe(-1) + expect(response.value.projections?.values).toEqual( + expect.objectContaining({ 'test/last-user': null }), + ) + }) + it('publishes the attachments imageLimits as a constant unit while both seams are composed', async () => { const { ctx, session } = await harness(true) const limits = { @@ -118,99 +156,112 @@ describe('session.history projections block', () => { saveImage(): Promise { return Promise.reject(new Error('unused')) } readImage(): Promise { return Promise.reject(new Error('unused')) } }) - const gateway = api(ctx) + const gateway = remote(ctx) seedMessages(session, 2) - const response = await gateway.sessions.history(request({ sessionId: session.id })) - if (!response.result.ok) throw new Error('history failed') - expect(response.result.value.projections?.values['imageLimits']).toEqual(limits) - // Constant unit: appending events must never broadcast an imageLimits frame. + const response = await page(gateway, request({ sessionId: session.id, throughSeq: session.seq - 1 })) + if (!response.ok) throw new Error('history failed') + expect(response.value.projections?.values['imageLimits']).toEqual(limits) + // Constant unit: appending events must never broadcast an imageLimits projection. await new Promise(resolve => setTimeout(resolve, 0)) const abort = new AbortController() - const stream = gateway.events.mux({ rpcId: RpcId('t-limits-mux'), payload: {} }, abort.signal) - const frames: MuxFrame[] = [] - const drained = (async () => { - for await (const envelope of stream) { - frames.push(envelope.payload) - if (frames.some(f => f.type === 'session/event')) abort.abort() - } - })().catch(() => {}) + const iterator = gateway.control(abort.signal)[Symbol.asyncIterator]() + await iterator.next() + const next = iterator.next() seedMessages(session, 1) - await drained - expect(frames.some(f => f.type === 'session/projection' && f.key === 'imageLimits')).toBe(false) + await new Promise(resolve => setTimeout(resolve, 0)) + await expect(next).resolves.toMatchObject({ + done: false, + value: { type: 'projection', key: 'sessionListMetadata' }, + }) + const extra = iterator.next() + const quiet = Symbol('quiet') + expect(await Promise.race([ + extra, + new Promise(resolve => setTimeout(() => { resolve(quiet) }, 0)), + ])).toBe(quiet) + abort.abort() + await expect(extra).resolves.toEqual({ done: true, value: undefined }) }) it('leaves the imageLimits key absent while no attachment service is composed', async () => { const { ctx, session } = await harness(true) seedMessages(session, 1) - const response = await api(ctx).sessions.history(request({ sessionId: session.id })) - if (!response.result.ok) throw new Error('history failed') - expect(response.result.value.projections).toBeDefined() - expect('imageLimits' in (response.result.value.projections?.values ?? {})).toBe(false) + const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) + if (!response.ok) throw new Error('history failed') + expect(response.value.projections).toBeDefined() + expect('imageLimits' in (response.value.projections?.values ?? {})).toBe(false) }) it('never carries the block on loadOlder pages (beforeSeq present)', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 5) - const older = await api(ctx).sessions.history(request({ sessionId: session.id, beforeSeq: 3, maxMessages: 2 })) - expect(older.result.ok).toBe(true) - if (!older.result.ok) throw new Error('unreachable') - expect('projections' in older.result.value).toBe(false) + const older = await page(remote(ctx), request({ + sessionId: session.id, throughSeq: session.seq - 1, beforeSeq: 3, maxMessages: 2, + })) + expect(older.ok).toBe(true) + if (!older.ok) throw new Error('unreachable') + expect('projections' in older.value).toBe(false) }) it('serves no block when the composition has no projection registry', async () => { const { ctx, session } = await harness(false) seedMessages(session, 2) - const response = await api(ctx).sessions.history(request({ sessionId: session.id })) - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - expect('projections' in response.result.value).toBe(false) + const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) + expect(response.ok).toBe(true) + if (!response.ok) throw new Error('unreachable') + expect('projections' in response.value).toBe(false) }) it('never exposes a host-only unit through history, listing, or push frames', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(internalCountUnit()) - const proxy = api(ctx) + const proxy = remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const abort = new AbortController() - const frames: MuxFrame[] = [] - const drained = (async () => { - for await (const envelope of proxy.events.mux({ rpcId: RpcId('t-host-only-mux'), payload: {} }, abort.signal)) { - frames.push(envelope.payload) - if (envelope.payload.type === 'session/event') abort.abort() - } - })().catch(() => {}) + const iterator = proxy.control(abort.signal)[Symbol.asyncIterator]() + const baseline = await iterator.next() + if (baseline.done || baseline.value.type !== 'baseline') { + throw new Error('control stream ended before its baseline') + } + expect('test/internal-count' in (baseline.value.value.projections[session.id]?.values ?? {})) + .toBe(false) seedMessages(session, 1) - await drained + const changed = await iterator.next() + expect(changed).toMatchObject({ + done: false, + value: { type: 'projection', key: 'sessionListMetadata' }, + }) + abort.abort() + await iterator.return?.() - const history = await proxy.sessions.history(request({ sessionId: session.id })) - if (!history.result.ok) throw new Error('history failed') - expect('test/internal-count' in (history.result.value.projections?.values ?? {})).toBe(false) - const listing = await proxy.sessions.list(request({})) - if (!listing.result.ok) throw new Error('listing failed') - const row = listing.result.value.items.find(item => item.sessionId === session.id) + const history = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 })) + if (!history.ok) throw new Error('history failed') + expect('test/internal-count' in (history.value.projections?.values ?? {})).toBe(false) + const listing = await proxy.list(request({})) + if (!listing.ok) throw new Error('listing failed') + const row = listing.value.items.find(item => item.sessionId === session.id) expect('test/internal-count' in (row?.projections?.values ?? {})).toBe(false) - expect(frames.some(frame => frame.type === 'session/projection' && frame.key === 'test/internal-count')).toBe(false) }) it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => { const { ctx, session } = await harness(true) const dispose = ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 1) - const proxy = api(ctx) - const before = await proxy.sessions.history(request({ sessionId: session.id })) - if (!before.result.ok) throw new Error('unreachable') - expect(before.result.value.projections?.values['test/last-user']).toEqual({ text: 'm0' }) + const proxy = remote(ctx) + const before = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 })) + if (!before.ok) throw new Error('unreachable') + expect(before.value.projections?.values['test/last-user']).toEqual({ text: 'm0' }) dispose() - const after = await proxy.sessions.history(request({ sessionId: session.id })) - if (!after.result.ok) throw new Error('unreachable') + const after = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 })) + if (!after.ok) throw new Error('unreachable') // The registry stays mounted; only the disposed key leaves while the // gateway-owned Session-list unit remains. - expect(after.result.value.projections?.asOfSeq).toBe(session.seq - 1) - expect('test/last-user' in (after.result.value.projections?.values ?? {})).toBe(false) - expect(after.result.value.projections?.values.sessionListMetadata).toEqual({ + expect(after.value.projections?.asOfSeq).toBe(session.seq - 1) + expect('test/last-user' in (after.value.projections?.values ?? {})).toBe(false) + expect(after.value.projections?.values.sessionListMetadata).toEqual({ blank: true, lastPromptAt: session.events.at(-1)?.time, }) @@ -220,8 +271,8 @@ describe('session.history projections block', () => { const { ctx, session } = await harness(true) expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false) const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => { - createApiProxy(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - }, { inject: ['sessions', 'agents', 'userQuestions', 'sessionProjections'] })) + createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + }, { inject: ['sessions', 'agents', 'sessionProjections'] })) await fiber.await() await vi.waitFor(() => { expect(ctx.sessionProjections.snapshot(session).values.sessionListMetadata) @@ -236,13 +287,13 @@ describe('session.list projections column', () => { it('serves attached rows from the live registry cut, watermarked for client seeding', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) - const gateway = api(ctx) + const gateway = remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) session.append('turn/start', { turn: 1 }) seedMessages(session, 1) - const response = await gateway.sessions.list(request({})) - if (!response.result.ok) throw new Error('unreachable') - const row = response.result.value.items.find(item => item.sessionId === session.id) + const response = await gateway.list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) expect(row?.projections?.values.sessionListMetadata).toEqual({ blank: false, @@ -254,9 +305,9 @@ describe('session.list projections column', () => { it('omits the column entirely when no registry is mounted', async () => { const { ctx, session } = await harness(false) seedMessages(session, 1) - const response = await api(ctx).sessions.list(request({})) - if (!response.result.ok) throw new Error('unreachable') - const row = response.result.value.items.find(item => item.sessionId === session.id) + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() expect(row !== undefined && 'projections' in row).toBe(false) }) @@ -279,9 +330,9 @@ describe('session.list projections column', () => { ? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } } : undefined), } as never) - const response = await api(ctx).sessions.list(request({})) - if (!response.result.ok) throw new Error('unreachable') - const row = response.result.value.items.find(item => item.sessionId === coldId) + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === coldId) expect(row?.running).toBe(false) expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }) }) @@ -293,9 +344,9 @@ describe('session.list projections column', () => { list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], locate: () => undefined, } as never) - const response = await api(ctx).sessions.list(request({})) - if (!response.result.ok) throw new Error('unreachable') - const row = response.result.value.items.find(item => item.sessionId === coldId) + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === coldId) expect(row).toBeDefined() expect(row !== undefined && 'projections' in row).toBe(false) }) @@ -310,21 +361,25 @@ describe('session.list projections column', () => { }, }) seedMessages(session, 1) - const response = await api(ctx).sessions.list(request({})) - if (!response.result.ok) throw new Error('unreachable') - const row = response.result.value.items.find(item => item.sessionId === session.id) + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() expect(row !== undefined && 'projections' in row).toBe(false) }) }) -describe('session/projection push frame', () => { - /** Drain frames until `count` session/projection frames arrived. */ - async function collect(iterable: AsyncIterable>, count: number, abort: AbortController): Promise { - const frames: MuxFrame[] = [] - for await (const envelope of iterable) { - frames.push(envelope.payload) - if (frames.filter(f => f.type === 'session/projection').length >= count) abort.abort() +describe('Session control projection frames', () => { + /** Drain frames until `count` projection replacements arrive. */ + async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, + ): Promise { + const frames: SessionControlFrame[] = [] + for await (const frame of iterable) { + frames.push(frame) + if (frames.filter(candidate => candidate.type === 'projection').length >= count) abort.abort() } return frames } @@ -332,12 +387,12 @@ describe('session/projection push frame', () => { it('broadcasts a frame per changed unit with the causing seq, and none for same-reference applies', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) - const proxy = api(ctx) - // The gateway's onChanged subscription lives in an inject child whose + const proxy = remote(ctx) + // The controller's onChanged subscription lives in an inject child whose // fiber activates asynchronously; yield until it lands before appending. await new Promise(resolve => setTimeout(resolve, 0)) const abort = new AbortController() - const stream = proxy.events.mux({ rpcId: RpcId('t-proj-mux'), payload: {} }, abort.signal) + const stream = proxy.control(abort.signal) const collected = collect(stream, 5, abort) const now = vi.spyOn(Date, 'now').mockReturnValue(100) @@ -350,41 +405,39 @@ describe('session/projection push frame', () => { const frames = await collected const pushes = frames.filter( - (f): f is Extract => - f.type === 'session/projection' && f.key === 'test/last-user', + (f): f is Extract => + f.type === 'projection' && f.key === 'test/last-user', ) expect(pushes).toEqual([ - { type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 }, - { type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 }, + { type: 'projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 }, + { type: 'projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 }, ]) expect(frames.filter( - (f): f is Extract => - f.type === 'session/projection' && f.key === 'sessionListMetadata', + (f): f is Extract => + f.type === 'projection' && f.key === 'sessionListMetadata', )).toEqual([ - { type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: true, lastPromptAt: 100 }, seq: 0 }, - { type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 100 }, seq: 1 }, - { type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 }, + { type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: true, lastPromptAt: 100 }, seq: 0 }, + { type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 100 }, seq: 1 }, + { type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 }, ]) // Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible). - const tail = await proxy.sessions.history(request({ sessionId: session.id })) - if (!tail.result.ok) throw new Error('unreachable') - expect(tail.result.value.projections?.asOfSeq).toBe(pushes.at(-1)?.seq) + const tail = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 })) + if (!tail.ok) throw new Error('unreachable') + expect(tail.value.projections?.asOfSeq).toBe(pushes.at(-1)?.seq) }) it('emits no projection frames when the composition has no registry', async () => { const { ctx, session } = await harness(false) - const proxy = api(ctx) + const control = new SessionControlController(ctx) const abort = new AbortController() - const stream = proxy.events.mux({ rpcId: RpcId('t-noproj-mux'), payload: {} }, abort.signal) - const frames: MuxFrame[] = [] - const drained = (async () => { - for await (const envelope of stream) { - frames.push(envelope.payload) - if (frames.filter(f => f.type === 'session/event').length >= 2) abort.abort() - } - })() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const baseline = await iterator.next() + const next = iterator.next() seedMessages(session, 2) - await drained - expect(frames.some(f => f.type === 'session/projection')).toBe(false) + await new Promise(resolve => setTimeout(resolve, 0)) + abort.abort() + if (baseline.done) throw new Error('Control stream ended before its baseline') + expect(baseline.value.type).toBe('baseline') + await expect(next).resolves.toEqual({ done: true, value: undefined }) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts similarity index 68% rename from packages/host/apiproxy/tests/api-proxy-rename.spec.ts rename to packages/api/session-controller/tests/session-rename.host.spec.ts index 28eaae1b0b..b73e72d0e3 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -1,9 +1,9 @@ /** - * sessions.rename delegation through the composed SessionTitleService. The + * Session Controller rename delegation through the composed SessionTitleService. The * agent factory is a structural stub whose createAgent forwards seed/meta into * the real SessionStore, and whose resume never runs (every source here is * already attached). Cold-session resolution is the shared `agentFor` path — - * api-proxy-cold.spec.ts owns the resume evidence for every unary that rides + * remote-proxy-cold.spec.ts owns the resume evidence for every unary that rides * it, rename included. */ @@ -14,24 +14,19 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionTitleService from '@deepseek-ai/dsh-session-title' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import type { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import { createSessionTestRemote } from './test-remote.ts' const sid = (id: string): SessionId => id as SessionId -let nextRpc = 1 -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`fr-${String(nextRpc++)}`), payload } +function request

(payload: P): P { + return payload } async function composed(withTitles = true): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) if (withTitles) { await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 }) } @@ -68,19 +63,19 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { return session } -const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) +const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-rename', 1) - const renamed = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' new name ' })) - expect(renamed.result.ok).toBe(true) - if (!renamed.result.ok) return - expect(renamed.result.value.title).toBe('new name') + const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: ' new name ' })) + expect(renamed.ok).toBe(true) + if (!renamed.ok) return + expect(renamed.value.title).toBe('new name') const event = source.events.findLast(item => item.type === 'session/title') - expect(event?.seq).toBe(renamed.result.value.seq) + expect(event?.seq).toBe(renamed.value.seq) expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } }) }) @@ -89,15 +84,15 @@ describe('sessions.rename', () => { const source = liveAgent(ctx, 'session-rename-bad', 1) // U+200B passes a client-side trim gate but normalizes to empty host-side. - const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ​ ' })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) { - expect(response.result.error).toMatchObject({ + const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ​ ' })) + expect(response.ok).toBe(false) + if (!response.ok) { + expect(response.error).toMatchObject({ code: 'title-invalid', details: { sessionId: source.id }, }) // The message renders verbatim in the rename dialog's alert. - expect(response.result.error.message).toBe('session title must contain visible characters') + expect(response.error.message).toBe('session title must contain visible characters') } }) @@ -110,20 +105,20 @@ describe('sessions.rename', () => { const stale = liveAgent(foreign, 'session-rename-stale', 1) ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent) - const response = await api(ctx).sessions.rename(request({ sessionId: stale.id, title: 'name' })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('internal') + const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' })) + expect(response.ok).toBe(false) + if (!response.ok) expect(response.error.code).toBe('internal') }) it('answers internal when the composition mounts no session-title service', async () => { const ctx = await composed(false) const source = liveAgent(ctx, 'session-no-titles', 1) - const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: 'name' })) - expect(response.result.ok).toBe(false) - if (!response.result.ok) { - expect(response.result.error.code).toBe('internal') - expect(response.result.error.message).toMatch(/mounts no session-title service/) + const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' })) + expect(response.ok).toBe(false) + if (!response.ok) { + expect(response.error.code).toBe('internal') + expect(response.error.message).toMatch(/mounts no session-title service/) } }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts similarity index 81% rename from packages/host/apiproxy/tests/api-proxy-search.spec.ts rename to packages/api/session-controller/tests/session-search.host.spec.ts index 711b60821e..173d20cf9e 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -1,5 +1,5 @@ /** - * Host session.search projection: list-equivalent visibility, fixed message + * Session Controller search projection: list-equivalent visibility, fixed message * filters and result bound, cancellation mapping, and unavailable/failure * behavior. */ @@ -11,15 +11,12 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import { SessionQueryError, type SessionSearchHit, type SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' -import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import { createSessionTestRemote } from './test-remote.ts' vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -29,8 +26,8 @@ vi.mock('node:fs/promises', async (importOriginal) => { const sid = (value: string): SessionId => value as SessionId const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } -function request(query: string): RpcRequest<{ query: string }> { - return { rpcId: RpcId(`search-${query}`), payload: { query } } +function request(query: string): { query: string } { + return { query } } function header(id: string, cwd: string | null = '/project'): SessionHeader { @@ -63,7 +60,6 @@ async function baseContext(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) return ctx } @@ -116,12 +112,12 @@ describe('session.search', () => { ], })) ctx.provide('sessionQuery', { searchSessions } as never) - const api = createApiProxy(ctx, defaults) + const remote = createSessionTestRemote(ctx, defaults) const signal = new AbortController().signal - const response = await api.sessions.search(request('matching answer'), signal) + const response = await remote.search(request(' matching answer '), signal) - expect(response.result).toEqual({ + expect(response).toEqual({ ok: true, value: { items: [{ sessionId: 'cold', snippet: 'the matching answer' }], @@ -147,18 +143,33 @@ describe('session.search', () => { expect(exec.signal).toBe(signal) }) + it('rejects invalid wire queries before invoking the search provider', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const searchSessions = vi.fn() + ctx.provide('sessionQuery', { searchSessions } as never) + const remote = createSessionTestRemote(ctx, defaults) + + for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) { + await expect(remote.search(request(query), new AbortController().signal)) + .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + } + expect(searchSessions).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + it('returns an empty page without invoking the index when no session is visible', async () => { const ctx = await baseContext() const searchSessions = vi.fn() ctx.provide('sessionQuery', { searchSessions } as never) - const api = createApiProxy(ctx, defaults) + const remote = createSessionTestRemote(ctx, defaults) - const response = await api.sessions.search( + const response = await remote.search( request('anything'), new AbortController().signal, ) - expect(response.result).toEqual({ + expect(response).toEqual({ ok: true, value: { items: [], hasMore: false }, }) @@ -187,12 +198,12 @@ describe('session.search', () => { }), } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('match'), new AbortController().signal, ) - expect(response.result).toEqual({ + expect(response).toEqual({ ok: true, value: { items: [{ sessionId: 'visible', snippet: 'allowed snippet' }], @@ -203,7 +214,7 @@ describe('session.search', () => { it('pages the globally ranked stream until the 20-item Host boundary is known', async () => { const ctx = await baseContext() - const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + const items = Array.from({ length: 22 }, (_, index) => hit(`visible-${index}`, index)) for (const item of items) { ctx.sessions.create(item.header.id, { meta: item.header }) } @@ -216,18 +227,18 @@ describe('session.search', () => { ctx.provide('sessionQuery', { searchSessions, } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('match'), new AbortController().signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: true, value: { hasMore: true }, }) - if (!response.result.ok) throw new Error('unreachable') - expect(response.result.value.items).toHaveLength(20) - expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19') + if (!response.ok) throw new Error('unreachable') + expect(response.value.items).toHaveLength(20) + expect(response.value.items.at(-1)?.sessionId).toBe('visible-19') expect(searchSessions).toHaveBeenCalledTimes(2) expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' }) }) @@ -257,17 +268,17 @@ describe('session.search', () => { }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('adaptive-page-limit'), new AbortController().signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: true, value: { hasMore: true }, }) - if (!response.result.ok) throw new Error('unreachable') - expect(response.result.value.items.map(item => item.sessionId)) + if (!response.ok) throw new Error('unreachable') + expect(response.value.items.map(item => item.sessionId)) .toEqual(items.slice(0, 20).map(item => item.header.id)) expect(searchSessions.mock.calls.map(([providerRequest]) => ({ limit: providerRequest.limit, @@ -300,15 +311,15 @@ describe('session.search', () => { }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('endless-pages'), new AbortController().signal, ) - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error).toMatchObject({ code: 'internal' }) - expect(response.result.error.message).toContain('100-call work budget') + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error.message).toContain('100-call work budget') expect(searchSessions).toHaveBeenCalledTimes(100) }) @@ -365,12 +376,12 @@ describe('session.search', () => { }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('stale-restart'), new AbortController().signal, ) - expect(response.result).toEqual({ + expect(response).toEqual({ ok: true, value: { items: [ @@ -404,16 +415,16 @@ describe('session.search', () => { }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('stale-churn'), new AbortController().signal, ) - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.code).toBe('internal') - expect(response.result.error.message).toContain('100-call work budget') - expect(response.result).not.toHaveProperty('value') + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error.code).toBe('internal') + expect(response.error.message).toContain('100-call work budget') + expect(response).not.toHaveProperty('value') expect(searchSessions).toHaveBeenCalledTimes(100) }) @@ -433,12 +444,12 @@ describe('session.search', () => { }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('abort-stale'), controller.signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: false, error: { code: 'cancelled' }, }) @@ -454,16 +465,16 @@ describe('session.search', () => { ))) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('first-page-stale'), new AbortController().signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: false, error: { code: 'internal' }, }) - expect(response.result).not.toHaveProperty('value') + expect(response).not.toHaveProperty('value') expect(searchSessions).toHaveBeenCalledOnce() }) @@ -478,12 +489,12 @@ describe('session.search', () => { )) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('continuation-invalid-limit'), new AbortController().signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: false, error: { code: 'internal' }, }) @@ -505,12 +516,12 @@ describe('session.search', () => { )) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('minimum-page-limit'), new AbortController().signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: false, error: { code: 'internal' }, }) @@ -531,12 +542,12 @@ describe('session.search', () => { }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('abort-invalid-limit'), controller.signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: false, error: { code: 'cancelled' }, }) @@ -550,15 +561,15 @@ describe('session.search', () => { const searchSessions = vi.fn(() => Promise.resolve({ items: oversized })) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('oversized-page'), new AbortController().signal, ) - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error).toMatchObject({ code: 'internal' }) - expect(response.result.error.message).toContain('returned 21 items; maximum is 20') + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error.message).toContain('returned 21 items; maximum is 20') }) it('uses the learned provider limit for the overproduction guard', async () => { @@ -576,15 +587,15 @@ describe('session.search', () => { }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('adapted-oversized-page'), new AbortController().signal, ) - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error).toMatchObject({ code: 'internal' }) - expect(response.result.error.message).toContain('returned 11 items; maximum is 10') + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error.message).toContain('returned 11 items; maximum is 10') expect(searchSessions).toHaveBeenCalledTimes(2) }) @@ -604,12 +615,12 @@ describe('session.search', () => { searchSessions: () => Promise.resolve({ items: [overlong] }), } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('bounded-snippet'), new AbortController().signal, ) - expect(response.result).toEqual({ + expect(response).toEqual({ ok: true, value: { items: [{ sessionId: 'visible', snippet: expected }], @@ -626,15 +637,15 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('repeated-cursor'), new AbortController().signal, ) - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error).toMatchObject({ code: 'internal' }) - expect(response.result.error.message).toContain('repeated a continuation cursor') + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error.message).toContain('repeated a continuation cursor') expect(searchSessions).toHaveBeenCalledTimes(2) }) @@ -649,18 +660,18 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('repeated-lookahead-cursor'), new AbortController().signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: false, error: { code: 'internal' }, }) - expect(response.result).not.toHaveProperty('value') - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.message).toContain('repeated a continuation cursor') + expect(response).not.toHaveProperty('value') + if (response.ok) throw new Error('unreachable') + expect(response.error.message).toContain('repeated a continuation cursor') expect(searchSessions).toHaveBeenCalledTimes(2) }) @@ -676,17 +687,17 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: items.slice(20) }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('duplicate-pages'), new AbortController().signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: true, value: { hasMore: true }, }) - if (!response.result.ok) throw new Error('unreachable') - expect(response.result.value.items.map(item => item.sessionId)).toEqual( + if (!response.ok) throw new Error('unreachable') + expect(response.value.items.map(item => item.sessionId)).toEqual( items.slice(0, 20).map(item => item.header.id), ) expect(searchSessions).toHaveBeenCalledTimes(3) @@ -704,12 +715,12 @@ describe('session.search', () => { }) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('cancel-continuation'), controller.signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: false, error: { code: 'cancelled' }, }) @@ -734,12 +745,12 @@ describe('session.search', () => { })) ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('large corpus'), new AbortController().signal, ) - expect(response.result).toEqual({ + expect(response).toEqual({ ok: true, value: { items: [{ sessionId: 'cold-32750', snippet: 'match 0' }], @@ -770,12 +781,12 @@ describe('session.search', () => { const searchSessions = vi.fn() ctx.provide('sessionQuery', { searchSessions } as never) - const response = await createApiProxy(ctx, defaults).sessions.search( + const response = await createSessionTestRemote(ctx, defaults).search( request('cancel-during-visibility'), controller.signal, ) - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: false, error: { code: 'cancelled' }, }) @@ -802,7 +813,7 @@ describe('session.search', () => { ctx.provide('sessionQuery', { searchSessions } as never) let settled = false - const responsePromise = createApiProxy(ctx, defaults).sessions.search( + const responsePromise = createSessionTestRemote(ctx, defaults).search( request('cancel-during-cold-stats'), controller.signal, ).finally(() => { @@ -819,7 +830,7 @@ describe('session.search', () => { for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 }) const response = await responsePromise - expect(response.result).toMatchObject({ + expect(response).toMatchObject({ ok: false, error: { code: 'cancelled' }, }) @@ -829,26 +840,26 @@ describe('session.search', () => { it('maps missing composition, query cancellation, and provider failure', async () => { const missingCtx = await baseContext() missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) - const missingApi = createApiProxy(missingCtx, defaults) + const missingApi = createSessionTestRemote(missingCtx, defaults) const preAborted = new AbortController() preAborted.abort() - const cancelledBeforeLookup = await missingApi.sessions.search( + const cancelledBeforeLookup = await missingApi.search( request('cancel-before-lookup'), preAborted.signal, ) - expect(cancelledBeforeLookup.result).toMatchObject({ + expect(cancelledBeforeLookup).toMatchObject({ ok: false, error: { code: 'cancelled' }, }) - const missing = await missingApi.sessions.search( + const missing = await missingApi.search( request('needle'), new AbortController().signal, ) - expect(missing.result.ok).toBe(false) - if (missing.result.ok) throw new Error('unreachable') - expect(missing.result.error.code).toBe('internal') - expect(missing.result.error.message).toContain('does not mount') + expect(missing.ok).toBe(false) + if (missing.ok) throw new Error('unreachable') + expect(missing.error.code).toBe('internal') + expect(missing.error.message).toContain('does not mount') const ctx = await baseContext() ctx.sessions.create(sid('visible'), { meta: header('visible') }) @@ -857,24 +868,24 @@ describe('session.search', () => { .mockRejectedValueOnce(aborted) .mockRejectedValueOnce(new Error('database unavailable')) ctx.provide('sessionQuery', { searchSessions } as never) - const api = createApiProxy(ctx, defaults) + const remote = createSessionTestRemote(ctx, defaults) - const cancelled = await api.sessions.search( + const cancelled = await remote.search( request('first'), new AbortController().signal, ) - expect(cancelled.result).toMatchObject({ + expect(cancelled).toMatchObject({ ok: false, error: { code: 'cancelled' }, }) - const failed = await api.sessions.search( + const failed = await remote.search( request('second'), new AbortController().signal, ) - expect(failed.result.ok).toBe(false) - if (failed.result.ok) throw new Error('unreachable') - expect(failed.result.error.code).toBe('internal') - expect(failed.result.error.message).toContain('database unavailable') + expect(failed.ok).toBe(false) + if (failed.ok) throw new Error('unreachable') + expect(failed.error.code).toBe('internal') + expect(failed.error.message).toContain('database unavailable') }) }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts new file mode 100644 index 0000000000..17533bb585 --- /dev/null +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -0,0 +1,729 @@ +/** Session object lifecycle, event-window transport, commands, and resync behavior. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types' +import { Session, type SessionOptions } from '../src/client/sessions/session.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import { entries, ev, plainTurn } from './event-script.client.ts' + +const SID = 'fk-s1' as SessionId +const PARENT = 'fk-parent' as SessionId + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function makeSession( + api = new FakeApiClient(), + options: SessionOptions = {}, +): { api: FakeApiClient; session: Session } { + return { api, session: new Session(SID, api, fakeRemote(api), options) } +} + +function follow( + api: FakeApiClient, + event: SessionEvent, + view?: SessionToolView, +): Promise { + return api.pushFollow(SID, { + type: 'event', + event: event as never, + ...(view === undefined ? {} : { view }), + }) +} + +function windowEntries(session: Session) { + return session.eventSource.getSnapshot().entries +} + +function eventSeqs(session: Session): number[] { + return windowEntries(session).map(entry => entry.event.seq) +} + +function histResponse(events: SessionEvent[], hasMore = false) { + // history returns HistoryEntry[] ({event, view?}); these tests are view-less. + return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) +} + +describe('Session open', () => { + it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { + const { session } = makeSession() + expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, running: false }) + + session.handleRunning(true) + expect(session.getSnapshot()).toMatchObject({ blank: false, running: true }) + }) + + it('installs the tail page: cold → loading → open with window and nodes in place', async () => { + const { api, session } = makeSession() + const page = plainTurn(10, 3, '问', '答') + api.onHistory = () => histResponse(page, true) + expect(session.getSnapshot().openState).toBe('cold') + const opening = session.open() + expect(session.getSnapshot().openState).toBe('loading') + await opening + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('open') + expect(snapshot.hasMore).toBe(true) + expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15]) + expect(session.eventSource.getSnapshot().change).toMatchObject({ kind: 'replace' }) + }) + + it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => { + const { api, session } = makeSession() + await Promise.all([session.open(), session.open()]) + await session.open() + expect(api.callsOf('session.history')).toHaveLength(1) + }) + + it('lands an error result in openState=error with the RpcError kept', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } })) + await session.open() + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('error') + expect(snapshot.openError?.code).toBe('session-not-found') + }) + + it('folds a transport throw into openState=error / internal', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.reject(new Error('socket died')) + await session.open() + expect(session.getSnapshot().openState).toBe('error') + expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' }) + }) + + it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { + const { api, session } = makeSession() + const gate = deferred>>() + api.onHistory = () => gate.promise + const opening = session.open() + // Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15). + const page = plainTurn(10, 0, '早', '安') + const deliveries = [ + follow(api, ev.turnStart(15, 1)), + follow(api, ev.user(16, '插进来的')), + ] + gate.resolve(ok({ + events: entries(page) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })) + await Promise.all([opening, ...deliveries]) + const seqs = eventSeqs(session) + // Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once. + expect(seqs).toEqual([10, 11, 12, 13, 14, 15, 16]) + }) +}) + + +describe('live event path', () => { + async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) { + const { api, session } = makeSession() + api.onHistory = () => histResponse(events) + await session.open() + return { api, session } + } + + it('drops replayed frames at or below the window tail', async () => { + const { api, session } = await opened() + const before = session.eventSource.getSnapshot() + await follow(api, ev.user(3, '重放')) + expect(session.eventSource.getSnapshot()).toBe(before) + }) + + it('keeps the authoritative host blank bit across unrelated log events', async () => { + const { api, session } = await opened([]) + session.handleBlank(true) + await Promise.all([ + follow(api, ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')), + follow(api, ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')), + ]) + const snapshot = session.getSnapshot() + expect(eventSeqs(session)).toEqual([0, 1]) + expect(snapshot.blank).toBe(true) + }) + + it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 + const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] + api.onHistory = () => histResponse(repaired) + // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires. + await follow(api, ev.assistant(9, 1, 'd')) + await vi.waitFor(() => { + expect(api.callsOf('session.history').length).toBe(2) + }) + await vi.waitFor(() => { + expect(eventSeqs(session)).toEqual( + repaired.filter(event => event.seq <= 9).map(event => event.seq), + ) + }) + }) +}) + +describe('paging', () => { + it('prepends an older page and keeps seq continuity', async () => { + const older = plainTurn(0, 0, '旧问', '旧答') + const newer = plainTurn(6, 1, '新问', '新答') + const { api, session } = makeSession() + api.onHistory = payload => payload.beforeSeq === undefined + ? histResponse(newer, true) + : histResponse(older, false) + await session.open() + await session.loadOlder() + const snapshot = session.getSnapshot() + expect(api.callsOf('session.history')).toMatchObject([ + { sessionId: SID, throughSeq: 11 }, + { sessionId: SID, throughSeq: 11, beforeSeq: 6 }, + ]) + expect(snapshot.hasMore).toBe(false) + expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq)) + }) + + it('installs a page without interpreting business replacement metadata', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse([ + ev.compactSummary(80, '窗外范围的摘要', 3, 40), + ev.compactCheckpoint(81, 80, 3, 40), + ev.user(82, '压缩后的新问题'), + ], true) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + await session.open() + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('open') + expect(eventSeqs(session)).toEqual([80, 81, 82]) + expect(errorSpy).not.toHaveBeenCalled() + } finally { + errorSpy.mockRestore() + } + }) + + it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => { + const { api, session } = makeSession() + api.onHistory = payload => payload.beforeSeq === undefined + ? histResponse(plainTurn(10, 1, '新', '页'), true) + : histResponse(plainTurn(0, 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + await session.open() + const windowBefore = session.eventSource.getSnapshot() + await session.loadOlder() + const snapshot = session.getSnapshot() + expect(session.eventSource.getSnapshot().entries).toEqual(windowBefore.entries) + expect(snapshot.hasMore).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + it('ignores loadOlder while one is in flight (single request)', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + await session.open() + const gate = deferred>>() + api.onHistory = () => gate.promise + const first = session.loadOlder() + const second = session.loadOlder() + gate.resolve(ok({ + events: entries(plainTurn(0, 0, 'a', 'b')) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })) + await Promise.all([first, second]) + expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two + }) +}) + +describe('prompt and cancel errors', () => { + it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { + const api = new FakeApiClient() + const session = new Session(SID, api, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') + const cancelled = await session.cancel() + + expect(prompted).toEqual({ ok: true, value: { accepted: true } }) + expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) + expect(api.callsOf('subagent.history')).toEqual([ + { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', throughSeq: -1, maxMessages: 50 }, + ]) + expect(api.callsOf('subagent.prompt')).toEqual([ + { + parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', + content: [{ type: 'text', text: '继续' }], + clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + ]) + expect(api.callsOf('subagent.interrupt')).toEqual([ + { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + ]) + expect(api.callsOf('session.history')).toEqual([]) + expect(api.callsOf('session.prompt')).toEqual([]) + expect(api.callsOf('session.cancel')).toEqual([]) + // A successful interrupt leaves no stop error behind. + expect(session.getSnapshot().promptError).toBeNull() + expect(session.getSnapshot().subagent).toEqual({ + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + }) + + it('lands an interrupt business failure in promptError with op=stop', async () => { + const api = new FakeApiClient() + api.onSubagentInterrupt = () => Promise.resolve(err({ + code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, + }) as never) + const session = new Session(SID, api, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + const cancelled = await session.cancel() + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } }) + expect(session.getSnapshot().promptError).toMatchObject({ + op: 'stop', error: { code: 'subagent-unauthorized' }, + }) + }) + + it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { + const api = new FakeApiClient() + const session = new Session(SID, api, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, + }) + await session.open() + const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') + const cancelled = await session.cancel() + + expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } }) + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) + expect(api.callsOf('subagent.history')).toEqual([ + { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', throughSeq: -1, maxMessages: 50 }, + ]) + expect(api.callsOf('subagent.prompt')).toEqual([]) + expect(api.callsOf('subagent.interrupt')).toEqual([]) + expect(api.callsOf('session.cancel')).toEqual([]) + }) + + it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => { + const { api, session } = makeSession() + session.handleBlank(true) + expect(session.getSnapshot()).toMatchObject({ + blank: true, promptAttempted: false, awaitingFirstTurn: false, + }) + const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue') + expect(session.getSnapshot()).toMatchObject({ + blank: true, promptAttempted: true, awaitingFirstTurn: true, + }) + const result = await inFlight + expect(result.ok).toBe(true) + expect(session.getSnapshot()).toMatchObject({ + blank: false, promptAttempted: true, awaitingFirstTurn: true, + }) + expect(api.callsOf('session.prompt')).toMatchObject([{ + sessionId: SID, + mode: 'queue', + content: [{ type: 'text', text: '要发的' }], + clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + }]) + session.handleRunning(true) + expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false }) + }) + + it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => { + const { api, session } = makeSession() + session.handleBlank(true) + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) + const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') + expect(result.ok).toBe(false) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } }) + expect(session.getSnapshot()).toMatchObject({ + blank: true, promptAttempted: true, awaitingFirstTurn: true, + }) + }) + + it('lands cancel failures in promptError with op=stop', async () => { + const { api, session } = makeSession() + api.onCancel = () => Promise.reject(new Error('cancel transport down')) + const result = await session.cancel() + expect(result.ok).toBe(false) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } }) + }) + + it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { + const { api, session } = makeSession() + const result = await session.readAttachment('attachment-1' as never) + expect(result).toEqual({ + ok: true, + value: { + attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: Uint8Array.of(0), + }, + }) + expect(api.callsOf('session.attachment')).toEqual([{ + sessionId: SID, attachmentId: 'attachment-1', + }]) + }) +}) + +describe('rename', () => { + it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => { + const { api, session } = makeSession() + api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 })) + const result = await session.rename(' 正名 ') + expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } }) + expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }]) + expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') + // A stale lower-seq apply (the push-frame path routes into this same + // store) must not roll the settled value back. + session.projections.apply('title', '旧名', 3) + expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') + }) + + it('returns the business error untouched and folds a transport throw to internal', async () => { + const { api, session } = makeSession() + api.onRename = () => Promise.resolve(err({ + code: 'title-invalid', message: 'empty', details: { sessionId: SID }, + } as never)) + const rejected = await session.rename(' ') + expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } }) + expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() + api.onRename = () => Promise.reject(new Error('rename transport down')) + const folded = await session.rename('x') + expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } }) + }) +}) + +describe('remaining branches', () => { + it('prompt transport throw folds to internal promptError', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.reject(new Error('prompt wire down')) + const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue') + expect(result.ok).toBe(false) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } }) + }) + + it('cancel business error also lands op=stop promptError', async () => { + const { api, session } = makeSession() + api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } })) + await session.cancel() + expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } }) + }) + + it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => { + const { api, session } = makeSession() + await session.loadOlder() // cold: no-op, zero calls + expect(api.calls).toEqual([]) + api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + await session.open() + // err result: window unchanged + api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) + await session.loadOlder() + expect(eventSeqs(session)).toHaveLength(6) + expect(session.getSnapshot().hasMore).toBe(true) + // empty page: hasMore adopts the response + api.onHistory = () => histResponse([], false) + await session.loadOlder() + expect(session.getSnapshot().hasMore).toBe(false) + // hasMore false now: further loadOlder is a guard no-op + const calls = api.calls.length + await session.loadOlder() + expect(api.calls.length).toBe(calls) + // throw path: fail-soft with console.error + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + await session.resync() + api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + await session.resync() + api.onHistory = () => Promise.reject(new Error('page wire down')) + await session.loadOlder() + expect(errorSpy).toHaveBeenCalled() + expect(session.getSnapshot().loadingOlder).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + it('subscribe delivers snapshot-change notifications and unsubscribes', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + let notified = 0 + const unsubscribe = session.subscribe(() => { notified++ }) + await session.open() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(notified).toBeGreaterThan(0) + const seen = notified + unsubscribe() + session.handleRunning(true) // any snapshot mutation; the listener must stay silent + await new Promise(resolve => setTimeout(resolve, 0)) + expect(notified).toBe(seen) + }) + + it('rejects an opening page that does not end at the opening cursor', async () => { + const { api, session } = makeSession() + let call = 0 + api.onHistory = () => { + call++ + return histResponse(plainTurn(0, 0, 'a', 'b')) + } + api.followCursor = 11 + await session.open() + expect(call).toBe(1) + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('error') + expect(snapshot.openError).toMatchObject({ + code: 'internal', message: 'session event stream page did not end at its requested cursor', + }) + expect(eventSeqs(session)).toEqual([]) + }) + + it('deduplicates repeated running flips and records removal', () => { + const { session } = makeSession() + const before = session.getSnapshot() + session.handleRunning(false) // already false: dedup branch + expect(session.getSnapshot()).toBe(before) + session.handleRemoved() + expect(session.getSnapshot().removed).toBe(true) + }) + + it('drops live events while cold/error (no window upkeep)', async () => { + const { api, session } = makeSession() + await follow(api, ev.user(0, '冷态帧')) + expect(eventSeqs(session)).toEqual([]) + api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) + await session.open() + await follow(api, ev.user(0, '错态帧')) + expect(eventSeqs(session)).toEqual([]) + }) + + it('preserves a Host-reported failure that terminates the live source', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const failure = { + code: 'session-not-found', + message: 'session disappeared', + details: { sessionId: SID }, + } + + api.failStreams(new RemoteStreamError(failure.code, failure.message, failure.details)) + await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) + + expect(session.getSnapshot().openError).toEqual(failure) + }) + + it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const gate = deferred>>() + let repairs = 0 + api.onHistory = () => { + repairs++ + return gate.promise + } + const deliveries = Promise.all([ + follow(api, ev.user(9, '洞一')), + follow(api, ev.user(10, '洞二')), + ]) + await vi.waitFor(() => { expect(repairs).toBe(1) }) + gate.reject(new Error('repair wire down')) + await deliveries + await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) + expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'repair wire down' }) + expect(eventSeqs(session)).toHaveLength(6) + }) + + it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => { + const { api, session } = makeSession() + const stale = deferred>>() + api.onHistory = () => stale.promise + const opening = session.open() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + const resynced = session.resync() + stale.reject(new Error('stale wire')) + await Promise.all([opening, resynced]) + expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error + }) + + it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => { + const { api, session } = makeSession() + const stale = deferred>>() + api.onHistory = () => stale.promise + const opening = session.open() + api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代')) + const resynced = session.resync() + stale.resolve(ok({ + events: entries(plainTurn(0, 0, '旧', '代')) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'stale' }, + })) // success, but its generation is gone + await Promise.all([opening, resynced]) + expect(eventSeqs(session)).toEqual(plainTurn(6, 1, '新', '代').map(event => event.seq)) + }) + + it('drops a gap repair superseded by a full resync while its pull was in flight', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const repairPull = deferred>>() + api.onHistory = () => repairPull.promise + const delivery = follow(api, ev.user(9, '洞')) + await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) }) + api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd')) + const resynced = session.resync() // bumps the generation + repairPull.resolve(ok({ + events: entries(plainTurn(0, 0, '旧', '页')) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'stale' }, + })) // repair result: stale, dropped + await Promise.all([delivery, resynced]) + expect(eventSeqs(session)).toEqual(plainTurn(6, 1, 'c', 'd').map(event => event.seq)) + }) + + it('successful cancel leaves no promptError', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const result = await session.cancel() + expect(result.ok).toBe(true) + expect(session.getSnapshot().promptError).toBeNull() + }) + + it('dispose is a reserved no-op on resident instances', async () => { + const { session } = makeSession() + await expect(session.dispose()).resolves.toBeUndefined() + }) + + it('carries history-entry and follow-frame views through the event feed', async () => { + const { api, session } = makeSession() + const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } } + api.onHistory = () => Promise.resolve(ok({ + events: [ + ...entries(plainTurn(0, 0, 'a', 'b')), + { event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView }, + { event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } }, + ] as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })) + await session.open() + expect(windowEntries(session).slice(-2).map(item => item.view)).toEqual([ + callView, + { for: 'result', view: { card: 'generic', title: '历史果' } }, + ]) + await follow( + api, + ev.toolCall(8, 2, 'l1', 'write', '{}'), + { for: 'call', view: { card: 'generic', title: '直播卡' } }, + ) + expect(windowEntries(session).at(-1)?.view).toEqual({ + for: 'call', view: { card: 'generic', title: '直播卡' }, + }) + await follow( + api, + ev.toolResult(9, 2, 'l1', 'ok'), + { for: 'result', view: { card: 'generic', title: '直播果' } }, + ) + expect(windowEntries(session).at(-1)?.view).toEqual({ + for: 'result', view: { card: 'generic', title: '直播果' }, + }) + }) +}) + +describe('resync', () => { + it('keeps the old feed until one sorted page-and-live replacement is ready', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, '旧', '窗')) + await session.open() + const oldWindow = session.eventSource.getSnapshot() + const replacement = deferred>>() + api.followCursor = 15 + api.onHistory = () => replacement.promise + const publications: ReturnType[] = [] + const off = session.eventSource.subscribe(() => { + publications.push(session.eventSource.getSnapshot()) + }) + + const syncing = session.resync() + await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) }) + expect(session.eventSource.getSnapshot()).toBe(oldWindow) + expect(publications).toEqual([]) + + await Promise.all([ + follow(api, ev.user(17, '后到高位')), + follow(api, ev.user(16, '后到低位')), + ]) + expect(session.eventSource.getSnapshot()).toBe(oldWindow) + replacement.resolve(ok({ + events: entries(plainTurn(10, 2, '终', '页')) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })) + await syncing + + expect(publications).toHaveLength(1) + expect(publications[0]?.entries).not.toHaveLength(0) + expect(publications[0]?.change.kind).toBe('replace') + expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17]) + off() + }) + + it('rebuilds the window without clearing control state; cold instances no-op', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + session.handleRunning(true) + session.handleAgentError('still visible') + api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) + await session.resync() + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('open') + expect(snapshot.running).toBe(true) + expect(snapshot.lastAgentError).toBe('still visible') + expect(eventSeqs(session)).toHaveLength(12) + + const cold = makeSession() + await cold.session.resync() + expect(cold.api.calls).toEqual([]) // never opened: no traffic + }) + + it('drops a stale in-flight open superseded by resync (generation guard)', async () => { + const { api, session } = makeSession() + const stale = deferred>>() + api.onHistory = () => stale.promise + const firstOpen = session.open() + api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代')) + const resynced = session.resync() + stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late + await firstOpen + await resynced + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error + expect(eventSeqs(session)).toEqual(plainTurn(6, 1, '新', '代').map(event => event.seq)) + }) + +}) + +describe('snapshot ownership', () => { + it('publishes event-window appends without changing an unrelated Session snapshot', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) + await session.open() + const sessionBefore = session.getSnapshot() + const windowBefore = session.eventSource.getSnapshot() + const firstEntry = windowBefore.entries[0] + await follow(api, ev.user(6, '追加')) + const windowAfter = session.eventSource.getSnapshot() + expect(session.getSnapshot()).toBe(sessionBefore) + expect(windowAfter).not.toBe(windowBefore) + expect(windowAfter.entries[0]).toBe(firstEntry) + expect(windowAfter.change).toMatchObject({ kind: 'append' }) + }) +}) diff --git a/packages/client/runtime/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts similarity index 74% rename from packages/client/runtime/tests/sessions-service.client.spec.ts rename to packages/api/session-controller/tests/sessions-service.client.spec.ts index b135b56ebe..461190f3ce 100644 --- a/packages/client/runtime/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -1,7 +1,7 @@ /** - * SessionRuntime: list store projection (manager → {ids, byId, current} - * with derived titles), the migrated current-selection account (open - * validation, persisted mask semantics, cell resolution), scope-tree + * ClientSessions: list store projection (manager → {ids, byId, current} + * with derived titles), the current-selection account (open validation and + * persisted mask semantics), scope-tree * lifecycle (lazy mint / frozen survival / removed teardown with staged * deferral — the stage follows list.current), binding identity, breadcrumb * projection, create. @@ -9,21 +9,31 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import { SessionCreateError, SessionRuntime, scopeOf } from '../src/client/sessions/service.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts' +import { scopeOf } from '../src/client/scope.ts' +import type { SessionFollowFrame } from '../src/types.ts' +import { + FakeApiClient, + deferred, + err, + fakeRemote, + ok, + type RuntimeRemotes, +} from './fake-api.client.ts' const sid = (s: string): SessionId => s as SessionId interface Bench { ctx: Context api: FakeApiClient - svc: SessionRuntime + svc: ClientSessions } -function bench(): Bench { +function bench(configureRemote?: (remote: RuntimeRemotes) => RuntimeRemotes): Bench { const ctx = new Context() const api = new FakeApiClient() - const svc = new SessionRuntime(ctx, api, fakeRemote()) + const remote = fakeRemote(api) + const svc = new ClientSessions(ctx, api, configureRemote?.(remote) ?? remote) return { ctx, api, svc } } @@ -55,9 +65,8 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise { describe('list store projection', () => { it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => { const b = bench() - b.svc.handleMuxEnvelope({ - rpcId: 'title' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2 } as never, + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2, }) await feedList(b, [ { id: 's1', cwd: '/home/u/proj-a/' }, @@ -90,7 +99,9 @@ describe('list store projection', () => { it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', blank: true, sessionId: sid('s2') } as never }) + b.svc.handleSessionAdded({ + sessionId: sid('s2'), updatedAt: 2, running: false, blank: true, + }) await Promise.resolve() expect(b.svc.list.getSnapshot().ids).toContain('s2') }) @@ -120,6 +131,22 @@ describe('search', () => { }) describe('scope tree', () => { + it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => { + const b = bench() + const scoped = b.svc.resolveAgentScope(sid('s-early')) + expect(scopeOf(scoped)).toBe('s-early') + + b.svc.handleControlFrame({ + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, + }) + await Promise.resolve() + expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped) + + await feedList(b, []) + expect(b.svc.scope(sid('s-early'))).toBeUndefined() + }) + it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) @@ -130,7 +157,7 @@ describe('scope tree', () => { expect(scopeOf(b.ctx)).toBeUndefined() const binding = b.svc.binding(sid('s1')) b.svc.open(sid('s1')) - expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session']) + expect(b.svc.sessionOf(scoped as Context)).toBe(binding?.session) expect(b.svc.binding(sid('s1'))).toBe(binding) expect(binding?.ctx).toBe(scoped) }) @@ -171,6 +198,190 @@ describe('scope tree', () => { b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1 expect(b.svc.scope(sid('s1'))).toBe(scoped) }) + + it('closes an opened journal when its removed scope drops', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + const session = b.svc.binding(sid('s1'))?.session + if (session === undefined) throw new Error('expected the selected Session binding') + await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(1) }) + const notified = vi.fn() + session.subscribe(notified) + + await feedList(b, []) + await feedList(b, [{ id: 's2' }]) + b.svc.open(sid('s2')) + + await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) }) + await b.api.pushFollow(sid('s1'), { + type: 'event', + event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never, + }) + await Promise.resolve() + expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(1) + expect(notified).not.toHaveBeenCalled() + }) +}) + +describe('Agent scope disposal lifecycle', () => { + it('root disposal runs Agent scope effects', async () => { + const b = bench() + const readiness = b.ctx.plugin(() => undefined) + await readiness + b.svc.handleSessionAdded({ + sessionId: sid('live'), updatedAt: 1, running: false, blank: true, + }) + await Promise.resolve() + const scoped = b.svc.scope(sid('live')) + if (scoped === undefined) throw new Error('fixture Agent Context was not minted') + await scoped.fiber.await() + const scopeDisposed = vi.fn() + scoped.effect(() => scopeDisposed, 'fixture Agent scope effect') + await b.ctx.fiber.dispose() + + expect(scopeDisposed).toHaveBeenCalledOnce() + expect(b.svc.sessionOf(scoped)).toBeUndefined() + }) + + it('root disposal waits for an opened Session source to finish closing', async () => { + const closeGate = deferred() + const abortObserved = vi.fn() + let followSignal: AbortSignal | undefined + const b = bench(remote => ({ + ...remote, + session: { + ...remote.session, + follow: (_request, signal) => { + if (signal === undefined) throw new Error('fixture requires a signal') + followSignal = signal + let opened = false + return { + [Symbol.asyncIterator]: () => ({ + next: () => { + if (!opened) { + opened = true + return Promise.resolve({ + done: false, + value: { type: 'opened', cursor: -1 } as const, + }) + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + abortObserved() + void closeGate.promise.then(() => { + reject(signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason))) + }) + }, { once: true }) + }) + }, + }), + } + }, + }, + })) + const readiness = b.ctx.plugin(() => undefined) + await readiness + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + await vi.waitFor(() => { + expect(b.svc.binding(sid('s1'))?.session.getSnapshot().openState).toBe('open') + }) + + const disposal = b.ctx.fiber.dispose() + const settled = vi.fn() + const observed = disposal.then(settled) + + await vi.waitFor(() => { expect(abortObserved).toHaveBeenCalledOnce() }) + expect(followSignal?.aborted).toBe(true) + expect(settled).not.toHaveBeenCalled() + + closeGate.resolve(undefined) + await observed + expect(settled).toHaveBeenCalledOnce() + }) + + it('root disposal joins every Session drop already started by pruning under load', async () => { + const closeGates = new Map>>() + const aborted = new Set() + const b = bench(remote => ({ + ...remote, + session: { + ...remote.session, + follow: (request, signal) => { + if (signal === undefined) throw new Error('fixture requires a signal') + const sessionId = request.address.kind === 'session' + ? request.address.sessionId + : request.address.childSessionId + const closeGate = deferred() + closeGates.set(sessionId, closeGate) + let opened = false + return { + [Symbol.asyncIterator]: () => ({ + next: () => { + if (!opened) { + opened = true + return Promise.resolve({ + done: false, + value: { type: 'opened', cursor: -1 } as const, + }) + } + return new Promise>((_resolve, reject) => { + signal.addEventListener('abort', () => { + aborted.add(sessionId) + void closeGate.promise.then(() => { + reject(signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason))) + }) + }, { once: true }) + }) + }, + }), + } + }, + }, + })) + const readiness = b.ctx.plugin(() => undefined) + await readiness + const sessionIds = Array.from({ length: 24 }, (_, index) => sid(`load-${String(index)}`)) + const retained = sessionIds.at(-1) + const held = sessionIds[0] + if (retained === undefined || held === undefined) throw new Error('fixture requires sessions') + await feedList(b, sessionIds.map(id => ({ id }))) + for (const id of sessionIds) b.svc.open(id) + await vi.waitFor(() => { + for (const id of sessionIds) { + expect(b.svc.binding(id)?.session.getSnapshot().openState).toBe('open') + } + }) + + const pruned = sessionIds.slice(0, -1) + await feedList(b, [{ id: retained }]) + await vi.waitFor(() => { expect(aborted.size).toBe(pruned.length) }) + for (const id of pruned) expect(b.svc.scope(id)).toBeUndefined() + + const disposal = b.ctx.fiber.dispose() + const settled = vi.fn() + const observed = disposal.then(settled) + await vi.waitFor(() => { expect(aborted.size).toBe(sessionIds.length) }) + + const otherClosures: Promise[] = [] + for (const [id, gate] of closeGates) { + if (id === held) continue + gate.resolve(undefined) + otherClosures.push(gate.promise) + } + await Promise.all(otherClosures) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(settled).not.toHaveBeenCalled() + + closeGates.get(held)?.resolve(undefined) + await observed + expect(settled).toHaveBeenCalledOnce() + }) }) describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => { @@ -233,78 +444,7 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s }) }) -describe('cell (render-layer session kit)', () => { - it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => { - const b = bench() - await feedList(b, [{ id: 's1' }]) - b.svc.open(sid('s1')) - const info = b.svc.currentProvideInfo.getSnapshot() - expect(info.sessionId).toBe('s1') - // The bundle carries bare observables; hook binding happens in React. - expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) - // Re-staging the same id republishes nothing: identity holds. - b.svc.open(sid('s1')) - expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info) - }) - - it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => { - const b = bench() - await feedList(b, [{ id: 's1' }, { id: 's2' }]) - const absent = b.svc.currentProvideInfo.getSnapshot() - expect(absent.sessionId).toBeUndefined() - expect(Object.hasOwn(absent.hooks, 'session')).toBe(true) - const notified = vi.fn() - b.svc.currentProvideInfo.subscribe(notified) - b.svc.open(sid('s1')) - const s1Bundle = b.svc.currentProvideInfo.getSnapshot() - expect(s1Bundle.sessionId).toBe('s1') - expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) - expect(notified).toHaveBeenCalledTimes(1) - b.svc.open(sid('s2')) - const s2Bundle = b.svc.currentProvideInfo.getSnapshot() - expect(s2Bundle.sessionId).toBe('s2') - expect(s2Bundle).not.toBe(s1Bundle) - expect(notified).toHaveBeenCalledTimes(2) - b.svc.clear() - await Promise.resolve() // clearSelection projects through the manager notifier - expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined() - }) - - it('a provider roster change under a stable current id republishes the bundle', async () => { - const b = bench() - await feedList(b, [{ id: 's1' }]) - b.svc.open(sid('s1')) - const before = b.svc.currentProvideInfo.getSnapshot() - const notified = vi.fn() - b.svc.currentProvideInfo.subscribe(notified) - const source = { getSnapshot: () => 'live', subscribe: () => () => {} } - const dispose = b.svc.provide({ - hooks: ['extra'], - props: ['marker'], - resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }), - }) - const added = b.svc.currentProvideInfo.getSnapshot() - expect(added).not.toBe(before) - expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } }) - expect(added.hooks['extra']).toBe(source) - expect(notified).toHaveBeenCalledTimes(1) - dispose() - const removed = b.svc.currentProvideInfo.getSnapshot() - expect(removed).not.toBe(added) - expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false) - expect(notified).toHaveBeenCalledTimes(2) - }) - - it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => { - const b = bench() - await feedList(b, [{ id: 's1' }]) - const notified = vi.fn() - const off = b.svc.currentProvideInfo.subscribe(notified) - off() - b.svc.open(sid('s1')) - expect(notified).not.toHaveBeenCalled() - }) - +describe('binding and stage lifecycle', () => { it('binding() is pure resolution: no staging, no deferred sweep', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) @@ -323,13 +463,17 @@ describe('cell (render-layer session kit)', () => { b.svc.binding(sid('s1')) expect(historyCalls()).toHaveLength(0) b.svc.open(sid('s1')) - expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1']) + await vi.waitFor(() => { + expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1']) + }) // Same current again: no second pull. b.svc.open(sid('s1')) expect(historyCalls()).toHaveLength(1) // Stage moves: the new occupant opens. b.svc.open(sid('s2')) - expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2']) + await vi.waitFor(() => { + expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2']) + }) }) it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => { @@ -344,40 +488,16 @@ describe('cell (render-layer session kit)', () => { const b = bench() expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0) await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows - const historyCalls = b.api.calls.filter(c => c.method === 'session.history') - expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1']) + await vi.waitFor(() => { + const historyCalls = b.api.calls.filter(c => c.method === 'session.history') + expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1']) + }) } finally { vi.unstubAllGlobals() } }) }) -describe('slot-store scope prune hook', () => { - it('notifies ctx.slots.pruneStoreScope when a scope dies (both teardown paths)', async () => { - const b = bench() - const pruneStoreScope = vi.fn() - b.ctx.reflect.provide('slots', { pruneStoreScope }) - await feedList(b, [{ id: 's1' }, { id: 's2' }]) - b.svc.scope(sid('s1')) - b.svc.scope(sid('s2')) - b.svc.open(sid('s2')) // s2 staged - await feedList(b, []) // s1 off stage → immediate drop; s2 staged → deferred - expect(pruneStoreScope).toHaveBeenCalledWith('s1') - expect(pruneStoreScope).not.toHaveBeenCalledWith('s2') - await feedList(b, [{ id: 's3' }]) - b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2 - expect(pruneStoreScope).toHaveBeenCalledWith('s2') - }) - - it('tolerates a slots-less boot (object-layer benches carry no slot service)', async () => { - const b = bench() - await feedList(b, [{ id: 's1' }]) - b.svc.scope(sid('s1')) - await feedList(b, []) // teardown without ctx.slots must not throw - expect(b.svc.scope(sid('s1'))).toBeUndefined() - }) -}) - describe('catalog-addressed navigation', () => { it('uses catalog labels for a listed addressed route', async () => { const b = bench() @@ -528,9 +648,8 @@ describe('fork', () => { ['计划 (9)', '计划 (10)'], ])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => { const b = bench() - b.svc.handleMuxEnvelope({ - rpcId: 'source-title' as never, - payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never, + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2, }) await feedList(b, [{ id: 'source', cwd: '/work' }]) b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) @@ -578,15 +697,14 @@ describe('fork', () => { it('rejects when child rename fails while keeping the published child addressable', async () => { const b = bench() - b.svc.handleMuxEnvelope({ - rpcId: 'source-title' as never, - payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never, + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2, }) await feedList(b, [{ id: 'source' }]) b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) b.api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') }, - })) + } as never)) await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })) .rejects.toThrow('fork child rename failed: title-invalid: rejected') @@ -599,18 +717,14 @@ describe('scope lifecycle rides the list mirror (entity parity: no client-side p const b = bench() await feedList(b, []) expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions - b.svc.handleHostEnvelope({ - rpcId: 'add' as never, - payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never, + b.svc.handleSessionAdded({ + sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a', }) await Promise.resolve() const scoped = b.svc.scope(sid('s-new')) expect(scoped).toBeDefined() expect(scopeOf(scoped as Context)).toBe('s-new') - b.svc.handleHostEnvelope({ - rpcId: 'rm' as never, - payload: { type: 'host/session-removed', sessionId: sid('s-new') }, - }) + b.svc.handleSessionRemoved(sid('s-new')) await Promise.resolve() expect(b.svc.scope(sid('s-new'))).toBeUndefined() }) @@ -621,10 +735,7 @@ describe('blank mirror', () => { const b = bench() await feedList(b, [{ id: 's1', blank: true }]) expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true }) - b.svc.handleHostEnvelope({ - rpcId: 'st' as never, - payload: { type: 'host/session-status', sessionId: sid('s1'), running: true }, - }) + b.svc.handleSessionStatus(sid('s1'), true) await Promise.resolve() expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true }) // The instantiated Session mirrors the same flip. @@ -669,9 +780,8 @@ describe('blank mirror', () => { it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => { const b = bench() await feedList(b, []) - b.svc.handleHostEnvelope({ - rpcId: 'add' as never, - payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never, + b.svc.handleSessionAdded({ + sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a', }) await Promise.resolve() expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true }) @@ -718,7 +828,7 @@ describe('coverage tails (branch duals)', () => { await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') - expect(historyCalls()).toHaveLength(1) + await vi.waitFor(() => { expect(historyCalls()).toHaveLength(1) }) await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred expect(b.svc.scope(sid('s1'))).toBeDefined() // Resurfacing re-projects current = s1: same stage occupant, no second pull. diff --git a/packages/client/runtime/tests/subagent-lineage.client.spec.ts b/packages/api/session-controller/tests/subagent-lineage.client.spec.ts similarity index 91% rename from packages/client/runtime/tests/subagent-lineage.client.spec.ts rename to packages/api/session-controller/tests/subagent-lineage.client.spec.ts index 05881576bf..f9c8ec0d69 100644 --- a/packages/client/runtime/tests/subagent-lineage.client.spec.ts +++ b/packages/api/session-controller/tests/subagent-lineage.client.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' -import { indexSubagentDescendants } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionSummary } from '../src/client/index.ts' +import { indexSubagentDescendants } from '../src/client/index.ts' const sid = (id: string) => id as SessionId diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts new file mode 100644 index 0000000000..2014938f3a --- /dev/null +++ b/packages/api/session-controller/tests/test-remote.ts @@ -0,0 +1,171 @@ +/** Test-only direct Remote face over the Session Controller's internal controllers. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' +import { vi } from 'vitest' +import { + TypertRemoteFailure, + type RemoteResult, +} from '@deepseek-ai/dsh-typert-protocol' +import SessionController from '../src/index.ts' +import type { + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionControlFrame, + SessionCreateRequest, + SessionCreateValue, + SessionForkRequest, + SessionForkValue, + SessionListRequest, + SessionListValue, + SessionModels, + SessionModelsRequest, + SessionPage, + SessionPageRequest, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSearchRequest, + SessionSearchValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, +} from '../src/types.ts' + +/** Direct test face matching the generated `ctx.remote.session` unary methods. */ +export interface TestSessionRemote { + list(request: SessionListRequest, signal?: AbortSignal): Promise> + search(request: SessionSearchRequest, signal?: AbortSignal): Promise> + create(request: SessionCreateRequest): Promise> + models(request: SessionModelsRequest): Promise> + selectModel(request: SessionSelectModelRequest): Promise> + rename(request: SessionRenameRequest): Promise> + fork(request: SessionForkRequest): Promise> + prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise> + attachment(request: SessionAttachmentRequest): Promise> + updateQueue(request: SessionUpdateQueueRequest): Promise> + cancel(request: SessionCancelRequest): Promise> + page(request: SessionPageRequest, signal?: AbortSignal): Promise> + control(signal?: AbortSignal): AsyncIterable +} + +/** Dependencies and policy supplied by a Session Controller unit harness. */ +export interface TestSessionRemoteDefaults { + readonly defaultModelSelection: () => AgentModelSelection + readonly cwd: string + readonly coldBlankProbeMaxBytes?: number + readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise +} + +const installed = new WeakMap() + +function installControllers( + ctx: Context, + defaults: TestSessionRemoteDefaults, +): SessionController { + const found = installed.get(ctx) + if (found !== undefined) return found + + if (ctx.get('typert') === undefined) { + const dispose = (): void => {} + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + } as never) + } + if (ctx.get('agentDefaultModel') === undefined) { + ctx.provide('agentDefaultModel', { + currentSelection: defaults.defaultModelSelection, + saveSelection: async (selection: AgentModelSelection) => { + await defaults.saveDefaultModelSelection?.(selection) + }, + } as never) + } + if (ctx.get('llm') === undefined) { + ctx.provide('llm', { + listProviders: () => { + const selection = defaults.defaultModelSelection() + return [{ id: selection.provider, name: selection.provider }] + }, + } as never) + } + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) + let controller: SessionController + try { + controller = new SessionController(ctx, defaults.coldBlankProbeMaxBytes === undefined + ? {} + : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }) + } finally { + cwd.mockRestore() + } + installed.set(ctx, controller) + return controller +} + +/** Build or return the production Session Controller for a direct unit harness. */ +export function createSessionTestController( + ctx: Context, + defaults: TestSessionRemoteDefaults, +): SessionController { + return installControllers(ctx, defaults) +} + +function remoteResult( + operation: () => T | Promise, + signal?: AbortSignal, +): Promise> { + return Promise.resolve() + .then(operation) + .then(value => ({ ok: true as const, value })) + .catch((error: unknown) => ({ + ok: false as const, + error: signal?.aborted === true + ? { code: 'cancelled', message: 'request was aborted', details: {} } + : error instanceof TypertRemoteFailure + ? error.failure + : { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + })) +} + +/** Build the generated Session Remote's unary result semantics without a carrier. */ +export function createSessionTestRemote( + ctx: Context, + defaults: TestSessionRemoteDefaults, +): TestSessionRemote { + const direct = createSessionTestController(ctx, defaults) + return { + list: (request, signal = new AbortController().signal) => remoteResult( + () => direct.list(request, signal), + signal, + ), + search: (request, signal = new AbortController().signal) => remoteResult( + () => direct.search(request, signal), + signal, + ), + create: request => remoteResult(() => direct.create(request)), + models: request => remoteResult(() => direct.models(request)), + selectModel: request => remoteResult(() => direct.selectModel(request)), + rename: request => remoteResult(() => direct.rename(request)), + fork: request => remoteResult(() => direct.fork(request)), + prompt: (request, signal = new AbortController().signal) => remoteResult( + () => direct.prompt(request, signal), + signal, + ), + attachment: request => remoteResult(() => direct.attachment(request)), + updateQueue: request => remoteResult(() => direct.updateQueue(request)), + cancel: request => remoteResult(() => direct.cancel(request)), + page: (request, signal = new AbortController().signal) => remoteResult( + () => direct.page(request, signal), + signal, + ), + control: (signal = new AbortController().signal) => direct.control(signal), + } +} diff --git a/packages/client/runtime/tests/time-zone.client.spec.ts b/packages/api/session-controller/tests/time-zone.client.spec.ts similarity index 92% rename from packages/client/runtime/tests/time-zone.client.spec.ts rename to packages/api/session-controller/tests/time-zone.client.spec.ts index d96c9476c1..983dabc20c 100644 --- a/packages/client/runtime/tests/time-zone.client.spec.ts +++ b/packages/api/session-controller/tests/time-zone.client.spec.ts @@ -5,7 +5,7 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('browser time zone', () => { +describe('Session Controller browser time zone', () => { it('returns the runtime-resolved zone', () => { expect(resolvedClientTimeZone()).toBe( new Intl.DateTimeFormat().resolvedOptions().timeZone, diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts new file mode 100644 index 0000000000..46c2064c9e --- /dev/null +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -0,0 +1,295 @@ +import { describe, expect, it, vi } from 'vitest' +import { + RemoteStream, + RemoteStreamCarrierError, + RemoteStreamError, + type RemoteStreamOptions, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { + createSessionControlStream, + SessionEventStream, + sessionStreamFailure, + type SessionJournalChange, + type SessionRemote, +} from '../src/client/index.ts' +import type { + SessionAddress, + SessionControlFrame, + SessionEventEntry, + SessionFollowFrame, + SessionFollowRequest, + SessionPage, + SessionPageRequest, +} from '../src/types.ts' + +type SessionTransportRemote = Pick + +const ADDRESS: SessionAddress = { kind: 'session', sessionId: 'session-1' as never } +const AVAILABLE_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + +function entry(seq: number): SessionEventEntry { + return { event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } } +} + +function page(events: readonly SessionEventEntry[], hasMore = false): SessionPage { + return { events, hasMore } +} + +function sessionClient(remote: SessionTransportRemote) { + return { + session: remote as SessionRemote, + $stream: (options: RemoteStreamOptions) => ( + new RemoteStream(AVAILABLE_CONNECTION, options) + ), + } +} + +interface FollowGeneration { + readonly frames: readonly SessionFollowFrame[] + readonly terminal?: Error + readonly hold?: boolean + readonly waitAfterFrames?: Promise +} + +class ScriptedSessionRemote implements SessionTransportRemote { + readonly followRequests: SessionFollowRequest[] = [] + readonly pageRequests: SessionPageRequest[] = [] + readonly signals: AbortSignal[] = [] + + constructor( + private readonly generations: FollowGeneration[], + private readonly pages: RemoteResult[], + private readonly controlFrames: readonly SessionControlFrame[] = [], + private readonly holdControl = true, + ) {} + + async *follow(request: SessionFollowRequest, signal = new AbortController().signal): AsyncIterable { + const generation = this.generations.shift() + if (generation === undefined) throw new Error('no scripted Session generation') + this.followRequests.push(request) + this.signals.push(signal) + for (const frame of generation.frames) yield frame + await generation.waitAfterFrames + if (generation.terminal !== undefined) throw generation.terminal + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + } + + page(request: SessionPageRequest): Promise> { + this.pageRequests.push(request) + const result = this.pages.shift() + if (result === undefined) throw new Error('no scripted Session page') + return Promise.resolve(result) + } + + async *control(signal = new AbortController().signal): AsyncIterable { + for (const frame of this.controlFrames) yield frame + if (this.holdControl && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + } +} + +describe('Session Client stream adapters', () => { + it('binds an event journal to one address and publishes replace, append, and prepend changes', async () => { + const remote = new ScriptedSessionRemote( + [{ + frames: [ + { type: 'opened', cursor: 3 }, + { type: 'event', ...entry(3) }, + { type: 'event', ...entry(4) }, + ], + hold: true, + }], + [ + { ok: true, value: page([entry(2), entry(3)], true) }, + { ok: true, value: page([entry(0), entry(1)], false) }, + ], + ) + const changes: SessionJournalChange[] = [] + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: (change) => { changes.push(change) }, + failed: vi.fn(), + }) + + await stream.open({ maxMessages: 50 }) + await vi.waitFor(() => { expect(changes).toHaveLength(2) }) + await stream.prepend({ beforeSeq: 2, maxMessages: 50 }) + + expect(remote.followRequests).toEqual([{ address: ADDRESS }]) + expect(remote.pageRequests).toEqual([ + { address: ADDRESS, throughSeq: 3, maxMessages: 50 }, + { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 }, + ]) + expect(changes).toMatchObject([ + { type: 'replace', entries: [entry(2), entry(3)], hasMore: true }, + { type: 'append', entry: entry(4) }, + { type: 'prepend', entries: [entry(0), entry(1)], hasMore: false }, + ]) + await stream.dispose() + expect(remote.signals[0]?.aborted).toBe(true) + }) + + it('resumes after the applied cursor and repairs through the addressed tail page', async () => { + const lost = new RemoteStreamCarrierError('lost') + const remote = new ScriptedSessionRemote( + [ + { + frames: [{ type: 'opened', cursor: 1 }, { type: 'event', ...entry(2) }], + terminal: lost, + }, + { frames: [{ type: 'opened', cursor: 4 }], hold: true }, + ], + [ + { ok: true, value: page([entry(0), entry(1)]) }, + { ok: true, value: page([entry(0), entry(1), entry(2), entry(3), entry(4)]) }, + ], + ) + const changes: SessionJournalChange[] = [] + const carrierFailed = vi.fn() + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: (change) => { changes.push(change) }, + carrierFailed, + failed: vi.fn(), + }) + + await stream.open({ maxMessages: 50 }) + await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) + + expect(remote.followRequests).toEqual([ + { address: ADDRESS }, + { address: ADDRESS, afterSeq: 2 }, + ]) + expect(remote.pageRequests).toEqual([ + { address: ADDRESS, throughSeq: 1, maxMessages: 50 }, + { address: ADDRESS, throughSeq: 4, maxMessages: 50 }, + ]) + expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) + expect(carrierFailed).toHaveBeenCalledWith(lost) + await stream.dispose() + }) + + it('repairs a resumed event stream without an optional message limit', async () => { + const finish = Promise.withResolvers() + const remote = new ScriptedSessionRemote( + [ + { + frames: [{ type: 'opened', cursor: 0 }], + waitAfterFrames: finish.promise, + terminal: new RemoteStreamCarrierError('lost'), + }, + { frames: [{ type: 'opened', cursor: 1 }], hold: true }, + ], + [ + { ok: true, value: page([entry(0)]) }, + { ok: true, value: page([entry(0), entry(1)]) }, + ], + ) + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: vi.fn(), + failed: vi.fn(), + }) + + await stream.open({}) + finish.resolve(undefined) + await vi.waitFor(() => { expect(remote.pageRequests).toHaveLength(2) }) + expect(remote.pageRequests).toEqual([ + { address: ADDRESS, throughSeq: 0 }, + { address: ADDRESS, throughSeq: 1 }, + ]) + await stream.dispose() + }) + + it('turns a page failure into a typed stream failure and closes follow', async () => { + const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const + const remote = new ScriptedSessionRemote( + [{ frames: [{ type: 'opened', cursor: -1 }], hold: true }], + [{ ok: false, error: failure }], + ) + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: vi.fn(), + failed: vi.fn(), + }) + + await expect(stream.open({})).rejects.toBeInstanceOf(RemoteStreamError) + await expect(stream.open({})).rejects.toThrow('already opened') + expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details))) + .toEqual(failure) + expect(sessionStreamFailure(new Error('local'))).toBeUndefined() + expect(remote.signals[0]?.aborted).toBe(true) + expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }]) + }) + + it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => { + const baseline: SessionControlFrame = { + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, + } + const update: SessionControlFrame = { + type: 'queue', sessionId: 'session-1' as never, items: [], + } + const remote = new ScriptedSessionRemote([], [], [baseline, update]) + const accept = vi.fn<(frame: SessionControlFrame) => void>() + const stream = createSessionControlStream(sessionClient(remote), { + accept, + failed: vi.fn(), + }) + + stream.start() + stream.start() + await vi.waitFor(() => { expect(accept).toHaveBeenCalledTimes(2) }) + expect(accept.mock.calls.map(([frame]) => frame)).toEqual([baseline, update]) + await stream.dispose() + await stream.dispose() + }) + + it('classifies control streams that end before and after their opening baseline', async () => { + const beforeFailed = vi.fn() + const before = createSessionControlStream( + sessionClient(new ScriptedSessionRemote([], [], [], false)), + { accept: vi.fn(), failed: beforeFailed }, + ) + before.start() + await vi.waitFor(() => { expect(beforeFailed).toHaveBeenCalledOnce() }) + expect(beforeFailed.mock.calls[0]?.[0]).toMatchObject({ + message: 'session control stream ended before its opening snapshot', + }) + await before.dispose() + + const baseline: SessionControlFrame = { + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, + } + const carrierFailed = vi.fn() + const failed = vi.fn() + const afterRemote = new ScriptedSessionRemote([], [], [baseline], false) + const after = createSessionControlStream(sessionClient(afterRemote), { + accept: vi.fn(), + carrierFailed: (error) => { + carrierFailed(error) + void after.dispose() + }, + failed, + }) + after.start() + await vi.waitFor(() => { expect(carrierFailed).toHaveBeenCalledOnce() }) + expect(carrierFailed.mock.calls[0]?.[0]).toMatchObject({ + message: 'session control stream ended without a terminal result', + }) + expect(failed).not.toHaveBeenCalled() + await after.dispose() + }) +}) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts new file mode 100644 index 0000000000..7bdb426e9d --- /dev/null +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -0,0 +1,652 @@ +import { Context } from '@deepseek-ai/cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { describe, expect, it, vi } from 'vitest' +import { SessionHistoryController } from '../src/history.ts' + +const signal = (): AbortSignal => new AbortController().signal + +function append( + session: Session, + type: string, + data: unknown, + options?: { readonly surfaceOp?: unknown; readonly sourceEventSeqs?: readonly number[] }, +): SessionEvent { + return (session.append as unknown as ( + eventType: string, + eventData: unknown, + eventOptions?: unknown, + ) => SessionEvent)(type, data, options) +} + +function event(type: string, seq: number, data: unknown = {}): SessionEvent { + return { type, seq, time: seq + 1, data } as SessionEvent +} + +function cold( + ctx: Context, + header: SessionHeader, + events: readonly SessionEvent[], +): void { + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([header]), + inspect: () => Promise.resolve({ meta: header, events }), + } as never) +} + +interface Deferred { + readonly promise: Promise + resolve(value: T): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} + +async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + const transport = new SessionHistoryController(ctx) + return { ctx, transport } +} + +describe('SessionHistoryController', () => { + it('opens at the current cursor and follows later events from an ordinary Session', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('ordinary'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + const abort = new AbortController() + const iterator = transport.follow( + { address: { kind: 'session', sessionId: session.id } }, + abort.signal, + )[Symbol.asyncIterator]() + + expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'opened', cursor: 0 } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(await iterator.next()).toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'turn/end', seq: 1 } }, + }) + + const page = await transport.page( + { address: { kind: 'session', sessionId: session.id }, throughSeq: 1 }, + new AbortController().signal, + ) + expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1]) + + abort.abort() + expect(await iterator.next()).toMatchObject({ done: true }) + }) + + it('ends active followers when the owning Controller unloads', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let transport!: SessionHistoryController + const owner = ctx.plugin(Object.assign( + (inner: Context) => { transport = new SessionHistoryController(inner) }, + { inject: ['sessions'] }, + )) + await owner.await() + const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } }) + const iterator = transport.follow( + { address: { kind: 'session', sessionId: session.id } }, + new AbortController().signal, + )[Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { type: 'opened', cursor: -1 }, + }) + const pending = iterator.next() + await owner.dispose() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + await ctx.fiber.dispose() + }) + + it('resumes from the last applied seq before delivering later live events', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2 }) + const abort = new AbortController() + const iterator = transport.follow({ + address: { kind: 'session', sessionId: session.id }, + afterSeq: 0, + }, abort.signal)[Symbol.asyncIterator]() + + expect(await iterator.next()).toEqual({ done: false, value: { type: 'opened', cursor: 2 } }) + expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 1 } } }) + expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 2 } } }) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 3 } } }) + + abort.abort() + expect(await iterator.next()).toMatchObject({ done: true }) + }) + + it('subscribes before a cold read and ignores unrelated and replayed buffered events', async () => { + const { ctx, transport } = await setup() + const sessionId = SessionId('cold-race') + const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const listed = deferred() + ctx.provide('sessionPersistence', { + list: () => listed.promise, + inspect: () => Promise.resolve({ meta: header, events: [event('fixture/start', 0)] }), + } as never) + const abort = new AbortController() + const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + [Symbol.asyncIterator]() + const opening = iterator.next() + + ctx.emit('session/event', { id: SessionId('unrelated') } as Session, event('fixture/other', 0)) + ctx.emit('session/event', { id: sessionId } as Session, event('fixture/start', 0)) + listed.resolve([header]) + await expect(opening).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } }) + + const waiting = iterator.next() + abort.abort() + await expect(waiting).resolves.toMatchObject({ done: true }) + }) + + it('bridges the unpublished end-seed boundary when a cold source attaches', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let transport!: SessionHistoryController + let agentCtx!: Context + await ctx.plugin(Object.assign( + (inner: Context) => { transport = new SessionHistoryController(inner) }, + { inject: ['sessions'] }, + )) + await ctx.plugin(Object.assign( + (inner: Context) => { agentCtx = createScope(inner, { name: 'agent' }).ctx }, + { inject: ['sessions'] }, + )) + const sessionId = SessionId('cold-attach') + const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const seed = [event('fixture/start', 0)] + cold(ctx, header, seed) + agentCtx.on('session/created', (session) => { + if (session.id !== sessionId) return + append(session, 'fixture/setup-one', {}) + append(session, 'fixture/setup-two', {}) + }) + const abort = new AbortController() + const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + [Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } }) + agentCtx.sessions.create(SessionId('unrelated-created'), { meta: { cwd: '/workspace' } }) + const attached = agentCtx.sessions.prepare(sessionId, { meta: header, seed }) + agentCtx.sessions.enter(attached) + agentCtx.sessions.announce(attached) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'session/end-seed', seq: 1 } }, + }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'fixture/setup-one', seq: 2 } }, + }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'fixture/setup-two', seq: 3 } }, + }) + append(attached, 'fixture/live', {}) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'fixture/live', seq: 4 } }, + }) + + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + }) + + it('rejects gaps in replayed and live event sequences', async () => { + const replay = await setup() + const replayId = SessionId('replay-gap') + const replayHeader = { version: 0, id: replayId, createdAt: 1, cwd: '/workspace' } + cold(replay.ctx, replayHeader, [event('fixture/start', 0), event('fixture/gap', 2)]) + const replayed = replay.transport.follow({ + address: { kind: 'session', sessionId: replayId }, afterSeq: -1, + }, signal())[Symbol.asyncIterator]() + await expect(replayed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 2 } }) + await expect(replayed.next()).resolves.toMatchObject({ done: false, value: { event: { seq: 0 } } }) + await expect(replayed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) + + const live = await setup() + const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } }) + append(session, 'fixture/start', {}) + live.ctx.provide('agents', { get: () => ({ id: session.id }) } as never) + const followed = live.transport.follow({ + address: { kind: 'session', sessionId: session.id }, + }, signal())[Symbol.asyncIterator]() + await expect(followed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } }) + live.ctx.emit('session/event', session, event('fixture/gap', 2)) + await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) + }) + + it('opens an empty source at cursor -1', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('empty-follow'), { meta: { cwd: '/workspace' } }) + const abort = new AbortController() + const iterator = transport.follow({ + address: { kind: 'session', sessionId: session.id }, + }, abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: -1 } }) + await expect(transport.page({ + address: { kind: 'session', sessionId: session.id }, throughSeq: -1, + }, signal())).resolves.toMatchObject({ events: [], hasMore: false }) + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + }) + + it('requires the durable parent and mode for a direct subagent address', async () => { + const { ctx, transport } = await setup() + const parentSessionId = SessionId('parent') + const childSessionId = SessionId('child') + ctx.sessions.create(parentSessionId, { meta: { cwd: '/workspace' } }) + const child = ctx.sessions.create(childSessionId, { + meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId }, + }) + child.append('subagent/descriptor', snapshotSubagentDescriptor({ + mode: 'continuable', + provider: 'test', + label: 'child', + })) + const signal = new AbortController().signal + + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' }, + throughSeq: 0, + }, signal)).resolves.toMatchObject({ events: [{ event: { type: 'subagent/descriptor' } }] }) + await expect(transport.page({ + address: { + kind: 'subagent', + parentSessionId: SessionId('other-parent'), + childSessionId, + mode: 'continuable', + }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + await expect(transport.page({ + address: { kind: 'session', sessionId: childSessionId }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + }) + + it('preserves a cold inspection failure for the Gateway error branch', async () => { + const { ctx, transport } = await setup() + const sessionId = SessionId('corrupt-cold') + const failure = new Error('cold log is corrupt') + const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([header]), + inspect: () => Promise.reject(failure), + } as never) + + await expect(transport.page({ + address: { kind: 'session', sessionId }, + throughSeq: -1, + }, new AbortController().signal)).rejects.toBe(failure) + }) + + it('rejects malformed page and follow cursors at the service boundary', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('validation'), { meta: { cwd: '/workspace' } }) + const address = { kind: 'session' as const, sessionId: session.id } + for (const request of [ + { address, throughSeq: -2 }, + { address, throughSeq: 0.5 }, + { address, throughSeq: -1, beforeSeq: -1 }, + { address, throughSeq: -1, beforeSeq: 1.5 }, + { address, throughSeq: -1, maxMessages: 0 }, + { address, throughSeq: -1, maxMessages: 1.5 }, + ]) { + await expect(transport.page(request, signal())).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + } + await expect(transport.page({ address, throughSeq: 0 }, signal())) + .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + + const corrupt = await setup() + const corruptId = SessionId('missing-through-seq') + cold( + corrupt.ctx, + { version: 0, id: corruptId, createdAt: 1, cwd: '/workspace' }, + [event('fixture/start', 0), event('fixture/gap', 2)], + ) + await expect(corrupt.transport.page({ + address: { kind: 'session', sessionId: corruptId }, throughSeq: 1, + }, signal())).rejects.toMatchObject({ failure: { code: 'internal' } }) + for (const afterSeq of [-2, 0.5]) { + const iterator = transport.follow({ address, afterSeq }, signal())[Symbol.asyncIterator]() + await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + } + const past = transport.follow({ address, afterSeq: 0 }, signal())[Symbol.asyncIterator]() + await expect(past.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + }) + + it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => { + const { ctx, transport } = await setup() + const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') } + await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'internal' } }) + + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([]), + inspect: () => Promise.reject(new Error('must not inspect')), + } as never) + await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + await expect(transport.page({ + address: { + kind: 'subagent', + parentSessionId: SessionId('parent'), + childSessionId: SessionId('missing-child'), + mode: 'continuable', + }, + throughSeq: -1, + }, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } }) + }) + + it('rejects incomplete cold metadata before serving a source', async () => { + const first = await setup() + const sessionId = SessionId('incomplete') + const address = { kind: 'session' as const, sessionId } + first.ctx.provide('sessionPersistence', { + list: () => Promise.resolve([{ version: 0, id: sessionId, createdAt: 1 }]), + inspect: () => Promise.reject(new Error('must not inspect')), + } as never) + await expect(first.transport.page({ address, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + + const second = await setup() + const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + second.ctx.provide('sessionPersistence', { + list: () => Promise.resolve([listed]), + inspect: () => Promise.resolve({ meta: { ...listed, cwd: undefined }, events: [] }), + } as never) + await expect(second.transport.page({ address, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + }) + + it('serves cold ordinary history and validates every durable subagent descriptor state', async () => { + const ordinaryBench = await setup() + const ordinaryId = SessionId('cold-ordinary') + const ordinaryHeader = { version: 0, id: ordinaryId, createdAt: 1, cwd: '/workspace' } + cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', 0, { turn: 1 })]) + await expect(ordinaryBench.transport.page({ + address: { kind: 'session', sessionId: ordinaryId }, + throughSeq: 0, + }, signal())).resolves.toMatchObject({ events: [{ event: { seq: 0 } }] }) + + const parentSessionId = SessionId('cold-parent') + const childSessionId = SessionId('cold-child') + const childHeader = { + version: 0, + id: childSessionId, + createdAt: 1, + cwd: '/workspace', + origin: 'subagent' as const, + parentSession: parentSessionId, + } + const childAddress = { + kind: 'subagent' as const, + parentSessionId, + childSessionId, + mode: 'continuable' as const, + } + const missing = await setup() + cold(missing.ctx, childHeader, []) + await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } } }) + + const corrupt = await setup() + cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })]) + await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal())) + .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } }) + + const ordinaryChild = await setup() + const { origin: _origin, ...ordinaryChildHeader } = childHeader + cold(ordinaryChild.ctx, ordinaryChildHeader, []) + await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + }) + + it('uses attached and detached projection cuts and isolates a child projection failure', async () => { + const attached = await setup() + const session = attached.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + const snapshot = vi.fn(() => ({ asOfSeq: 0, values: { title: 'attached' } })) + attached.ctx.provide('sessionProjections', { snapshot, restore: vi.fn() } as never) + await expect(attached.transport.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: 0, + }, signal())).resolves.toMatchObject({ projections: { asOfSeq: 0, values: { title: 'attached' } } }) + expect(snapshot).toHaveBeenCalledWith(session) + const older = await attached.transport.page({ + address: { kind: 'session', sessionId: session.id }, throughSeq: 0, beforeSeq: 1, + }, signal()) + expect('projections' in older).toBe(false) + + const detached = await setup() + const coldId = SessionId('projected-cold') + const header = { version: 0, id: coldId, createdAt: 1, cwd: '/workspace' } + cold(detached.ctx, header, [event('turn/start', 0, { turn: 1 })]) + const restore = vi.fn(() => ({ snapshot: { asOfSeq: 0, values: { title: 'cold' } } })) + detached.ctx.provide('sessionProjections', { snapshot: vi.fn(), restore } as never) + await expect(detached.transport.page({ + address: { kind: 'session', sessionId: coldId }, + throughSeq: 0, + }, signal())).resolves.toMatchObject({ projections: { values: { title: 'cold' } } }) + expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0) + + const failed = await setup() + cold(failed.ctx, header, [event('turn/start', 0, { turn: 1 })]) + failed.ctx.provide('sessionProjections', { + snapshot: vi.fn(), + restore: () => { throw new Error('projection failed') }, + } as never) + await expect(failed.transport.page({ + address: { kind: 'session', sessionId: coldId }, + throughSeq: 0, + }, signal())).rejects.toThrow('projection failed') + + const child = await setup() + const parentSessionId = SessionId('projection-parent') + const childSessionId = SessionId('projection-child') + const childSession = child.ctx.sessions.create(childSessionId, { + meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId }, + }) + childSession.append('subagent/descriptor', snapshotSubagentDescriptor({ + mode: 'continuable', provider: 'test', label: 'child', + })) + const warn = vi.spyOn(child.ctx.logger, 'warn').mockImplementation(() => undefined) + child.ctx.provide('sessionProjections', { + snapshot: () => { throw new Error('child projection failed') }, + restore: vi.fn(), + } as never) + const page = await child.transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' }, + throughSeq: 0, + }, signal()) + expect('projections' in page).toBe(false) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed')) + }) + + it('resolves presenter scope from a live Agent or the durable preset and tolerates lookup failure', async () => { + const live = await setup() + const liveSession = live.ctx.sessions.create(SessionId('live-scope'), { meta: { cwd: '/workspace' } }) + const liveAgent = { id: liveSession.id } + const preset = vi.fn(() => Promise.resolve('preset-scope')) + live.ctx.provide('agents', { get: () => liveAgent } as never) + live.ctx.provide('agentPresets', { standingKeyFor: preset } as never) + await live.transport.page({ + address: { kind: 'session', sessionId: liveSession.id }, throughSeq: -1, + }, signal()) + expect(preset).not.toHaveBeenCalled() + + const attached = await setup() + const attachedSession = attached.ctx.sessions.create(SessionId('preset-scope'), { + meta: { cwd: '/workspace', agentPreset: 'minimal' }, + }) + const standingKeyFor = vi.fn(() => Promise.resolve('standing-scope')) + attached.ctx.provide('agentPresets', { standingKeyFor } as never) + await attached.transport.page({ + address: { kind: 'session', sessionId: attachedSession.id }, + throughSeq: -1, + }, signal()) + expect(standingKeyFor).toHaveBeenCalledWith('minimal') + + const detached = await setup() + const detachedId = SessionId('detached-scope') + const header = { + version: 0, id: detachedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard', + } + cold(detached.ctx, header, []) + const rejected = vi.fn(() => Promise.reject(new Error('preset unavailable'))) + detached.ctx.provide('agentPresets', { standingKeyFor: rejected } as never) + await expect(detached.transport.page({ + address: { kind: 'session', sessionId: detachedId }, + throughSeq: -1, + }, signal())).resolves.toMatchObject({ events: [] }) + expect(rejected).toHaveBeenCalledWith('standard') + + const switched = await setup() + const switchedId = SessionId('switched-scope') + const switchedHeader = { + version: 0, id: switchedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard', + } + cold(switched.ctx, switchedHeader, [ + event('agent-preset/selected', 0, { agentPreset: 'minimal' }), + ]) + const switchedKey = vi.fn(() => Promise.resolve('switched-scope')) + switched.ctx.provide('agentPresets', { standingKeyFor: switchedKey } as never) + await switched.transport.page({ + address: { kind: 'session', sessionId: switchedId }, throughSeq: 0, + }, signal()) + expect(switchedKey).toHaveBeenCalledWith('minimal') + }) + + it('keeps message-aligned pagination contiguous across replacement provenance', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const firstReply = append(session, 'assistant/message', { turn: 1, step: 1, message: {} }, { surfaceOp: 'append' }) + append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) + append(session, 'assistant/message', { turn: 1, step: 2, message: {} }, { surfaceOp: 'append' }) + const summary = append(session, 'fixture/summary', {}) + const replacement = append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, { + surfaceOp: { op: 'replace', start: 1, end: 4 }, + sourceEventSeqs: [1, firstReply.seq, 3, 4, summary.seq], + }) + + const page = await transport.page({ + address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2, + }, signal()) + expect(page.events.map(entry => entry.event.seq)).toEqual([3, 4, 5, replacement.seq]) + expect(page.hasMore).toBe(true) + const before = await transport.page({ + address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, beforeSeq: 3, maxMessages: 1, + }, signal()) + expect(before.events.map(entry => entry.event.seq)).toEqual([2]) + }) + + it('keeps cited source events in the page that owns their appended message', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('pagination-sources'), { meta: { cwd: '/workspace' } }) + const source = append(session, 'fixture/source', {}) + append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, { + surfaceOp: 'append', sourceEventSeqs: [source.seq], + }) + + const page = await transport.page({ + address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1, + }, signal()) + expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1]) + expect(page.hasMore).toBe(false) + }) + + it('projects tool call and result views and contains malformed presenters', async () => { + const { ctx, transport } = await setup() + const sessionId = SessionId('presenters') + const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const events = [ + event('fixture/start', 0), + event('tool/call', 1, { callId: 'c1', name: 'present', arguments: '{"path":"a.ts"}' }), + event('tool/result', 2, { + message: { + source: { callId: 'c1' }, + content: [{ content: [{ type: 'text', text: 'ok' }], isError: true }], + }, + meta: { persisted: true }, + }), + event('tool/result', 3, { + message: { + source: { callId: 'missing' }, + content: [{ content: [{ type: 'text', text: 'missing' }] }], + }, + }), + event('tool/call', 4, { callId: 'c2', name: 'present', arguments: '{' }), + event('tool/result', 5, { + message: { + source: { callId: 'c2' }, + content: [{ content: [{ type: 'text', text: 'bad args' }] }], + }, + }), + event('tool/call', 6, { callId: 'c3', name: 'empty', arguments: '{}' }), + event('tool/result', 7, { + message: { + source: { callId: 'c3' }, + content: [{ content: [{ type: 'text', text: 'no presenter' }], isError: false }], + }, + }), + event('tool/call', 8, { callId: 'c4', name: 'throw-call', arguments: '{}' }), + event('tool/call', 9, { callId: 'c5', name: 'throw-result', arguments: '{}' }), + event('tool/result', 10, { + message: { + source: { callId: 'c5' }, + content: [{ content: [{ type: 'text', text: 'throw' }], isError: false }], + }, + }), + ] + cold(ctx, header, events) + ctx.provide('tools', { + get: (name: string) => { + if (name === 'present') { + return { + presentCall: (args: unknown) => ({ card: 'generic', title: 'Call', rawInput: args }), + presentResult: (_args: unknown, result: unknown) => ({ card: 'generic', title: 'Result', result }), + } + } + if (name === 'empty') return {} + if (name === 'throw-call') return { presentCall: () => { throw new Error('call presenter failed') } } + if (name === 'throw-result') return { presentResult: () => { throw new Error('result presenter failed') } } + return undefined + }, + } as never) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + const page = await transport.page({ + address: { kind: 'session', sessionId }, throughSeq: 10, + }, signal()) + expect(page.events[1]?.view).toEqual({ + for: 'call', view: { card: 'generic', title: 'Call', rawInput: { path: 'a.ts' } }, + }) + expect(page.events[2]?.view).toMatchObject({ for: 'result', view: { card: 'generic', title: 'Result' } }) + for (const index of [0, 3, 4, 5, 6, 7, 8, 9, 10]) { + expect(page.events[index]).not.toHaveProperty('view') + } + expect(warn).toHaveBeenCalledWith(expect.stringContaining('call presenter failed')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('result presenter failed')) + }) +}) diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json new file mode 100644 index 0000000000..5703caf280 --- /dev/null +++ b/packages/api/session-controller/tsconfig.client.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src/client/**/*.ts", + "src/types.ts", + "src/remote-events.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../attachment/attachment" }, + { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../llm/llm" }, + { "path": "../../session/session-projection" }, + { "path": "../../session/session-title" }, + { "path": "../../core/tools" }, + { "path": "../../util/brand" }, + { "path": "../../util/crypto" }, + { "path": "../../workspace/workspace" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json new file mode 100644 index 0000000000..efceb803df --- /dev/null +++ b/packages/api/session-controller/tsconfig.host.json @@ -0,0 +1,44 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/invariant.ts", + "src/types.ts", + "src/remote-events.ts", + "src/agent.ts", + "src/catalog.ts", + "src/commands.ts", + "src/control.ts", + "src/history.ts", + "src/list.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/agent" }, + { "path": "../../core/agent-default-model" }, + { "path": "../../core/scope" }, + { "path": "../../core/session" }, + { "path": "../../core/tools" }, + { "path": "../../attachment/attachment" }, + { "path": "../../interaction/permission-presets" }, + { "path": "../../jobs/jobs" }, + { "path": "../../llm/llm" }, + { "path": "../../preset/agent-presets" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../session/session-persistence" }, + { "path": "../../session/session-projection" }, + { "path": "../../session/session-projection-cache" }, + { "path": "../../session/session-title" }, + { "path": "../../session-query/session-query" }, + { "path": "../../subagent/subagent" }, + { "path": "../../typert/protocol" }, + { "path": "../../typert/registry" }, + { "path": "../../workspace/workspace" } + ] +} diff --git a/packages/api/session-controller/tsconfig.json b/packages/api/session-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/session-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/session-controller/tsdown.config.ts b/packages/api/session-controller/tsdown.config.ts new file mode 100644 index 0000000000..9ac9ebf59b --- /dev/null +++ b/packages/api/session-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-session-controller', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml new file mode 100644 index 0000000000..fedd773a90 --- /dev/null +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md +README.md: 0e126f1a0cc52353cb479f42a592e8997207e2e5 +README.zh.md: 2a1c56d8c02ffe7ac6a797b2c620ce3be42a3cc4 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md new file mode 100644 index 0000000000..0e126f1a0c --- /dev/null +++ b/packages/api/workspace-controller/README.md @@ -0,0 +1,22 @@ +# Workspace Controller + +English | [中文](README.zh.md) + +`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive Sessions from Workspace navigation, and follow the complete Workspace projection. + +The Host controller serializes mutations whose correctness depends on current registry state and returns stable `WorkspaceError` values for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. + +The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived Session ids, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. + +## Model Experience + +None, as Workspace organization is browser and Host control state and registers no prompt, tool, or session event. + +#### KV Cache effect + +No direct effect; Workspace mutations do not alter model requests. + +## Known Limitations and Deferred Work + +- `follow()` replaces the whole projection after reconnect and has no durable cursor or incremental catch-up protocol. +- Process-local deletion markers prevent delayed data from reviving a removed Workspace only for the lifetime of the Client model. diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md new file mode 100644 index 0000000000..2a1c56d8c0 --- /dev/null +++ b/packages/api/workspace-controller/README.zh.md @@ -0,0 +1,22 @@ +# Workspace Controller + +[English](README.md) | 中文 + +`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,从 Workspace 导航中归档 Session,以及跟随完整的 Workspace 投影。 + +Host 控制器会串行执行正确性取决于当前 registry 状态的变更,并为预期失败返回稳定的 `WorkspaceError` 值。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。 + +Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、已归档 Session id、一元变更回声,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。该包公开与框架无关的快照和订阅,把导航策略与 React hook 留给 UI owner。 + +## 模型体验 + +无,因为 Workspace 组织属于浏览器与 Host 控制状态,并且不注册提示词、工具或会话事件。 + +#### KV Cache 影响 + +无直接影响;Workspace 变更不会改变模型请求。 + +## 已知限制与延期工作 + +- `follow()` 在重连后替换完整投影,不提供持久 cursor 或增量追赶协议。 +- 进程本地删除标记只会在 Client 模型生命周期内阻止延迟数据复活已移除的 Workspace。 diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json new file mode 100644 index 0000000000..29245fef86 --- /dev/null +++ b/packages/api/workspace-controller/package.json @@ -0,0 +1,96 @@ +{ + "name": "@deepseek-ai/dsh-api-workspace-controller", + "description": "Workspace Remote commands and reconnect-safe state transport", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/workspace-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./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" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^" + } +} diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts new file mode 100644 index 0000000000..0ec758391e --- /dev/null +++ b/packages/api/workspace-controller/src/client/index.ts @@ -0,0 +1,125 @@ +/** Workspace-specific adapter for the Gateway-owned snapshot stream lifecycle. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from '../types.ts' +import type { WorkspaceFollowSink, WorkspaceRemote } from './model.ts' +import { ClientWorkspaceModel } from './model.ts' +import { WorkspaceController } from './service.ts' + +export { ClientWorkspaceModel } from './model.ts' +export type { + WorkspaceFollowSink, WorkspaceListPhase, WorkspaceRemote, WorkspaceSnapshot, +} from './model.ts' +export { abbreviateHomePath, resolveWorkspacePath } from './path.ts' +export { WorkspaceController, WorkspaceCreateError } from './service.ts' +export type { IWorkspaces, WorkspaceSource } from './service.ts' +export type { WorkspaceId, WorkspaceView } from '../types.ts' + +type WorkspaceStreamRemote = Pick & { + readonly workspace: WorkspaceRemote +} + +type WorkspaceBaselineFrame = Extract + +/** Gateway-owned snapshot stream configured for Workspace state. */ +export type WorkspaceStateStream = RemoteSnapshotStream< + WorkspaceBaselineFrame, + WorkspaceFollowIncrement +> + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free Client Workspace state and commands. */ + workspaces: import('./service.ts').IWorkspaces + } +} + +/** Required Client Remote services. */ +export const inject = ['remote', 'remote.workspace'] + +/** + * Install Client Workspace state, commands, and reconnecting follow control. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + const remote = ctx.remote as WorkspaceStreamRemote + const model = new ClientWorkspaceModel(remote.workspace) + new WorkspaceController(ctx, model) + const control = createWorkspaceStateStream(remote, { + accept: model, + carrierFailed: () => { model.handleCarrierFailure() }, + failed: (error) => { model.handleStreamFailure(error) }, + }) + control.start() + ctx.effect( + () => async () => { await control.dispose() }, + 'workspace-controller.client.control', + ) +} + +/** Domain sinks used by the Workspace state stream. */ +export interface WorkspaceStateStreamOptions { + /** Destinations for decoded Workspace state operations. */ + readonly accept: WorkspaceFollowSink + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void +} + +/** + * Create the reconnecting Workspace state stream. + * @param remote - generated Workspace namespace and Gateway stream factory. + * @param options - Workspace state destinations. + * @returns an unstarted stream owned by the Client Workspace runtime. + */ +export function createWorkspaceStateStream( + remote: WorkspaceStreamRemote, + options: WorkspaceStateStreamOptions, +): WorkspaceStateStream { + const stream = remote.$stream({ + name: 'Workspace state stream', + open: signal => remote.workspace.follow(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('Workspace state stream ended without a terminal result') + : new Error('Workspace state stream ended before its opening snapshot'), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }) + return new RemoteSnapshotStream(stream, { + name: 'Workspace state stream', + isSnapshot: (frame): frame is WorkspaceBaselineFrame => frame.type === 'baseline', + replace: (frame) => { options.accept.replaceBaseline(frame.value) }, + update: (frame) => { acceptIncrement(options.accept, frame) }, + failed: options.failed, + }) +} + +function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncrement): void { + switch (frame.type) { + case 'upsert': + accept.upsertView(frame.workspace) + return + case 'remove': + accept.removeView(frame.workspaceId) + return + case 'order': + accept.replaceOrder(frame.workspaceIds) + return + case 'archived': + accept.replaceArchived(frame.archivedSessionIds) + return + /* v8 ignore next -- the generated Remote codec validates this closed union */ + default: + return assertNever(frame) + } +} + +/* v8 ignore next 3 -- closed-union backstop after generated Remote validation */ +function assertNever(value: never): never { + throw new Error(`unreachable Workspace increment: ${JSON.stringify(value)}`) +} diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts new file mode 100644 index 0000000000..2ec365e8b2 --- /dev/null +++ b/packages/api/workspace-controller/src/client/model.ts @@ -0,0 +1,383 @@ +/** Client-side Workspace state model shared by Remote transport and UI projection. */ + +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' +import type { RemoteFailure, RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceBaseline, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteValue, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceValue, + WorkspaceId, + WorkspaceView, +} from '../types.ts' + +/** Complete generated `ctx.remote.workspace` namespace. */ +export type WorkspaceRemote = TypertClientRemote['workspace'] + +/** Monotone Workspace-list arrival lifecycle. */ +export type WorkspaceListPhase = 'pending' | 'ready' + +/** Immutable Client Workspace state. */ +export interface WorkspaceSnapshot { + readonly items: readonly WorkspaceView[] + /** Complete registry-global archive set in Host order. */ + readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] + readonly state: 'idle' | 'loading' | 'error' + readonly phase: WorkspaceListPhase + readonly error: RemoteFailure | null +} + +/** State operations emitted by a decoded Workspace follow generation. */ +export interface WorkspaceFollowSink { + /** Replace all state from the generation baseline. */ + replaceBaseline(value: WorkspaceBaseline): void + /** Merge one Workspace row. */ + upsertView(workspace: WorkspaceView): void + /** Remove one Workspace row. */ + removeView(workspaceId: WorkspaceId): void + /** Replace the Host-confirmed Workspace order. */ + replaceOrder(workspaceIds: readonly WorkspaceId[]): void + /** Replace the complete archived Session set. */ + replaceArchived(sessionIds: WorkspaceArchiveValue['archivedSessionIds']): void +} + +/** + * Owns the Client Workspace projection, mutation echoes, and stream/unary race resolution. + */ +export class ClientWorkspaceModel implements WorkspaceFollowSink { + private items: readonly WorkspaceView[] = [] + private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = [] + private state: WorkspaceSnapshot['state'] = 'loading' + private phase: WorkspaceListPhase = 'pending' + private error: RemoteFailure | null = null + /** Latest local reorder request; only its unary echo may install order. */ + private orderRequestGeneration = 0 + /** Increments on stream orders so a later remote commit outranks an older unary echo. */ + private orderFrameGeneration = 0 + /** Last complete order accepted from a baseline, increment, or current unary echo. */ + private committedOrder: WorkspaceId[] = [] + /** Host Workspace ids are never reused, so delayed data cannot resurrect a removed row. */ + private readonly removedIds = new Set() + private readonly listeners = new Set<() => void>() + private snapshotCache: WorkspaceSnapshot + private snapshotDirty = false + private notificationPending = false + private notificationScheduled = false + private notificationGeneration = 0 + + /** @param remote - generated Workspace Remote namespace. */ + constructor(private readonly remote: WorkspaceRemote) { + this.snapshotCache = this.buildSnapshot() + } + + /** + * Create or resolve a Workspace and merge the unary result immediately. + * @param input - existing absolute path to adopt. + * @returns generated Remote result. + */ + async create(input: WorkspaceCreateRequest): Promise> { + let result: RemoteResult + try { + result = await this.remote.create(input) + } catch (error) { + result = failureResult(error) + } + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Rename a Workspace and merge the unary result immediately. + * @param workspaceId - target Workspace. + * @param title - new display title. + * @returns generated Remote result. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise> { + const result = await this.remote.rename({ workspaceId, title }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Delete a Workspace and remove it from the local projection immediately. + * @param workspaceId - target Workspace. + * @returns generated Remote result. + */ + async delete(workspaceId: WorkspaceId): Promise> { + const result = await this.remote.delete({ workspaceId }) + if (result.ok) this.remove(workspaceId, true) + return result + } + + /** + * Optimistically move a Workspace and reconcile the returned complete order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - anchor Workspace; omitted appends. + * @returns generated Remote result. + */ + async insertBefore( + workspaceId: WorkspaceId, + beforeWorkspaceId?: WorkspaceId, + ): Promise> { + const requestGeneration = ++this.orderRequestGeneration + const frameGeneration = this.orderFrameGeneration + const localOrder = this.items.map(workspace => workspace.workspaceId) + this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId)) + let result: RemoteResult + try { + result = await this.remote.insertBefore({ + workspaceId, + ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, + }) + } catch (error) { + if (requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(this.committedOrder) + } + throw error + } + if (requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(result.ok ? result.value.workspaceIds : this.committedOrder, result.ok) + } + return result + } + + /** + * Move a Session within its Workspace and merge the returned row. + * @param workspaceId - owning Workspace. + * @param sessionId - accounted Session to move. + * @param beforeSessionId - accounted anchor; omitted appends. + * @returns generated Remote result. + */ + async insertSessionBefore( + workspaceId: WorkspaceInsertSessionBeforeRequest['workspaceId'], + sessionId: WorkspaceInsertSessionBeforeRequest['sessionId'], + beforeSessionId?: WorkspaceInsertSessionBeforeRequest['beforeSessionId'], + ): Promise> { + const result = await this.remote.insertSessionBefore({ + workspaceId, + sessionId, + ...beforeSessionId === undefined ? {} : { beforeSessionId }, + }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Archive one Session and install the returned complete archive set. + * @param sessionId - Session to archive. + * @returns generated Remote result. + */ + async archiveSession( + sessionId: WorkspaceArchiveSessionRequest['sessionId'], + ): Promise> { + const result = await this.remote.archiveSession({ sessionId }) + if (result.ok) this.installArchived(result.value.archivedSessionIds) + return result + } + + /** + * Replace the projection from one complete stream-generation baseline. + * @param baseline - complete Workspace and archive projection. + */ + replaceBaseline(baseline: WorkspaceBaseline): void { + this.orderFrameGeneration++ + this.installViews(baseline.items) + this.installArchived(baseline.archivedSessionIds) + this.state = 'idle' + this.phase = 'ready' + this.error = null + this.invalidate() + } + + /** Merge one decoded Workspace upsert from the current follow generation. */ + upsertView(workspace: WorkspaceView): void { + this.upsert(workspace) + } + + /** Apply one decoded Workspace removal from the current follow generation. */ + removeView(workspaceId: WorkspaceId): void { + this.remove(workspaceId) + } + + /** Replace Host-confirmed order from the current follow generation. */ + replaceOrder(workspaceIds: readonly WorkspaceId[]): void { + this.orderFrameGeneration++ + this.installOrder(workspaceIds, true) + } + + /** + * Replace the archived Session set from the current follow generation. + * @param archivedSessionIds - complete Host-confirmed archive set. + */ + replaceArchived(archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']): void { + this.installArchived(archivedSessionIds) + } + + /** Keep the last complete projection visible while a lost carrier reconnects. */ + handleCarrierFailure(): void { + this.state = 'loading' + this.error = null + this.invalidate() + } + + /** + * Publish a non-retryable stream or protocol failure. + * @param error - terminal stream failure. + */ + handleStreamFailure(error: unknown): void { + this.state = 'error' + this.error = failureOf(error) + this.invalidate() + } + + /** + * Subscribe to Workspace state invalidation. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Read the cached state, rebuilding it first when necessary. + * @returns the current stable Workspace list snapshot. + */ + getSnapshot(): WorkspaceSnapshot { + this.refreshSnapshot() + return this.snapshotCache + } + + private buildSnapshot(): WorkspaceSnapshot { + return { + items: this.items, + archivedSessionIds: this.archivedSessionIds, + state: this.state, + phase: this.phase, + error: this.error, + } + } + + private installArchived(archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']): void { + if (archivedSessionIds.length === this.archivedSessionIds.length + && archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return + this.archivedSessionIds = [...archivedSessionIds] + this.invalidate() + } + + private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void { + if (committed) this.committedOrder = [...workspaceIds] + const rank = new Map(workspaceIds.map((id, index) => [id, index])) + const items = [...this.items].sort((left, right) => + (rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER) + - (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER)) + if (items.every((item, index) => item === this.items[index])) return + this.items = items + this.invalidate() + } + + private upsert(view: WorkspaceView): void { + if (this.removedIds.has(view.workspaceId)) return + const index = this.items.findIndex(item => item.workspaceId === view.workspaceId) + const installed = this.items[index] + // Unary responses and stream increments race on separate requests. Keep + // the newest Host projection regardless of their arrival order. + if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return + if (!this.committedOrder.includes(view.workspaceId)) { + this.committedOrder = [view.workspaceId, ...this.committedOrder] + } + this.items = index === -1 + ? [view, ...this.items] + : this.items.map((item, position) => position === index ? view : item) + this.invalidate() + } + + private remove(workspaceId: WorkspaceId, immediate = false): void { + this.removedIds.add(workspaceId) + this.committedOrder = this.committedOrder.filter(id => id !== workspaceId) + const items = this.items.filter(item => item.workspaceId !== workspaceId) + if (items.length === this.items.length) { + // A successful unary echo still publishes an earlier increment's + // pending removal before the user operation resolves. + if (immediate) this.invalidate(true) + return + } + this.items = items + this.invalidate(immediate) + } + + private installViews(views: readonly WorkspaceView[]): void { + const installed = new Map() + for (const view of views) { + if (!this.removedIds.has(view.workspaceId)) installed.set(view.workspaceId, view) + } + this.items = [...installed.values()] + this.committedOrder = views.map(view => view.workspaceId) + } + + private invalidate(immediate = false): void { + this.snapshotDirty = true + this.notificationPending = true + if (immediate) { + this.notificationGeneration++ + this.notificationScheduled = false + this.flush() + return + } + if (this.notificationScheduled) return + this.notificationScheduled = true + const generation = ++this.notificationGeneration + queueMicrotask(() => { + if (generation !== this.notificationGeneration) return + this.notificationScheduled = false + this.flush() + }) + } + + private flush(): void { + if (!this.notificationPending || this.listeners.size === 0) return + this.notificationPending = false + this.refreshSnapshot() + notifySubscribers(this.listeners, '[workspace-controller]') + } + + private refreshSnapshot(): void { + if (!this.snapshotDirty) return + this.snapshotDirty = false + this.snapshotCache = this.buildSnapshot() + } +} + +function insertIdBefore( + ids: readonly WorkspaceId[], + id: WorkspaceId, + beforeId?: WorkspaceId, +): WorkspaceId[] { + if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) { + return [...ids] + } + const without = ids.filter(candidate => candidate !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + return [...without.slice(0, at), id, ...without.slice(at)] +} + +function failureResult(error: unknown): RemoteResult { + return { ok: false, error: failureOf(error) } +} + +function failureOf(error: unknown): RemoteFailure { + return { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + } +} diff --git a/packages/client/runtime/src/client/workspaces/path.ts b/packages/api/workspace-controller/src/client/path.ts similarity index 86% rename from packages/client/runtime/src/client/workspaces/path.ts rename to packages/api/workspace-controller/src/client/path.ts index 8bb3aa6645..334c56b007 100644 --- a/packages/client/runtime/src/client/workspaces/path.ts +++ b/packages/api/workspace-controller/src/client/path.ts @@ -1,8 +1,8 @@ /** * Resolve a workspace-relative path into the Host-facing spelling used by openPath. - * @param cwd - session workspace root, when known. - * @param path - absolute or workspace-relative path. - * @returns an absolute path when a workspace root is available, otherwise the original path. + * @param cwd - Session Workspace root, when known. + * @param path - absolute or Workspace-relative path. + * @returns an absolute path when a Workspace root is available, otherwise the original path. */ export function resolveWorkspacePath(cwd: string | undefined, path: string): string { if (path.startsWith('/') || isWindowsStylePath(path)) return path @@ -22,7 +22,7 @@ function isWindowsStylePath(value: string): boolean { * verbatim, including when `home` itself is a Windows path. A missing, empty, * or filesystem-root `home` leaves `path` unchanged so `/` cannot become `~`. * @param path - absolute or already-short display path. - * @param home - host account home from `host.describe`; absent skips abbreviation. + * @param home - Host account home from `host.describe`; absent skips abbreviation. * @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`. */ export function abbreviateHomePath(path: string, home?: string): string { diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts new file mode 100644 index 0000000000..993ac55ca6 --- /dev/null +++ b/packages/api/workspace-controller/src/client/service.ts @@ -0,0 +1,131 @@ +/** React-free Client Workspace service and command facade. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import type { WorkspaceId, WorkspaceView } from '../types.ts' +import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts' + +/** Structured create failure for callers that distinguish Host business errors. */ +export class WorkspaceCreateError extends Error { + override readonly name = 'WorkspaceCreateError' + + /** @param rpcError - Host business or folded transport failure. */ + constructor(readonly rpcError: RemoteFailure) { + super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`) + } +} + +/** Bare observable source for the Workspace Controller snapshot. */ +export interface WorkspaceSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): WorkspaceSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** Workspace Controller's Client service face. */ +export interface IWorkspaces { + /** Host-authoritative Workspace rows, order, archive set, and follow lifecycle. */ + readonly list: WorkspaceSource + /** + * Register an existing path as a Workspace. + * @param input - Host create payload. + * @returns the created or idempotently resolved Workspace. + */ + create(input: { path: string }): Promise + /** + * Rename a Workspace. + * @param workspaceId - target Workspace. + * @param title - new display title. + * @returns the renamed Workspace. + */ + rename(workspaceId: WorkspaceId, title: string): Promise + /** + * Delete a Workspace registration without deleting Sessions or files. + * @param workspaceId - target Workspace. + */ + delete(workspaceId: WorkspaceId): Promise + /** + * Move a Workspace within the Host registry order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - anchor Workspace; omitted appends. + */ + insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise + /** + * Archive a Session from Workspace grouping surfaces. + * @param sessionId - Session to archive. + */ + archiveSession(sessionId: SessionId): Promise + /** + * Move a Session within one Workspace account. + * @param workspaceId - owning Workspace. + * @param sessionId - Session to move. + * @param beforeSessionId - anchor Session; omitted appends. + * @returns the changed Workspace. + */ + insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise +} + +/** Owns the bare Workspace snapshot and Workspace-only commands. */ +export class WorkspaceController extends Service implements IWorkspaces { + readonly list: WorkspaceSource + + /** + * @param ctx - Client root Context. + * @param model - Remote-backed Workspace state model. + */ + constructor(ctx: Context, private readonly model: ClientWorkspaceModel) { + super(ctx, 'workspaces') + this.list = model + } + + async create(input: { path: string }): Promise { + const result = await this.model.create(input) + if (!result.ok) throw new WorkspaceCreateError(result.error) + return result.value.workspace + } + + async rename(workspaceId: WorkspaceId, title: string): Promise { + const result = await this.model.rename(workspaceId, title) + if (!result.ok) throw commandError('rename', result.error) + return result.value.workspace + } + + async delete(workspaceId: WorkspaceId): Promise { + const result = await this.model.delete(workspaceId) + if (!result.ok) throw commandError('delete', result.error) + } + + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + const result = await this.model.insertBefore(workspaceId, beforeWorkspaceId) + if (!result.ok) throw commandError('reorder', result.error) + } + + async archiveSession(sessionId: SessionId): Promise { + const result = await this.model.archiveSession(sessionId) + if (!result.ok) throw commandError('session archive', result.error) + } + + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise { + const result = await this.model.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + if (!result.ok) throw commandError('move', result.error) + return result.value.workspace + } +} + +function commandError(operation: string, failure: RemoteFailure): Error { + return new Error(`workspace ${operation} failed: ${failure.code}: ${failure.message}`) +} diff --git a/packages/api/workspace-controller/src/commands.ts b/packages/api/workspace-controller/src/commands.ts new file mode 100644 index 0000000000..0bb36b0897 --- /dev/null +++ b/packages/api/workspace-controller/src/commands.ts @@ -0,0 +1,196 @@ +/** Workspace command implementation and stable Remote failure mapping. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Workspace } from '@deepseek-ai/dsh-workspace' +import { + WorkspaceId, + WorkspaceMoveInvalidError, + WorkspaceOrderInvalidError, + WorkspaceUnknownSessionError, +} from '@deepseek-ai/dsh-workspace' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { workspaceView } from './feed.ts' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceValue, +} from './types.ts' + +/** Implements Workspace mutations against the authoritative registry. */ +export class WorkspaceCommands { + private operationTail = Promise.resolve() + + /** @param ctx - Host context containing the Workspace registry. */ + constructor(private readonly ctx: Context) {} + + /** + * Create or resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ + create(request: WorkspaceCreateRequest): Promise { + return this.enqueue(async () => { + try { + const existing = await this.ctx.workspaceRegistry.resolveByPath(request.path) + if (existing !== undefined) { + return { workspace: workspaceView(existing), created: false } + } + const workspace = await this.ctx.workspaceRegistry.create(request.path) + return { workspace: workspaceView(workspace), created: true } + } catch (error) { + if (error instanceof TypertRemoteFailure) throw error + throw failure( + 'workspace-invalid-path', + `cannot create a Workspace at "${request.path}": ${errorMessage(error)}`, + { path: request.path }, + ) + } + }) + } + + /** + * Rename one Workspace after serializing title ownership checks. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ + rename(request: WorkspaceRenameRequest): Promise { + const title = request.title.trim() + if (title === '') { + return Promise.reject(failure( + 'bad-request', + 'Workspace rename requires a non-blank title', + {}, + )) + } + return this.enqueue(async () => { + const workspace = this.requireWorkspace(request.workspaceId) + if (title !== workspace.title) { + if (this.ctx.workspaceRegistry.list().some(candidate => + candidate.id !== workspace.id && candidate.title === title)) { + throw failure( + 'workspace-name-conflict', + `Workspace name '${title}' is already in use`, + { name: title }, + ) + } + await workspace.setTitle(title) + } + return { workspace: workspaceView(workspace) } + }) + } + + /** + * Delete one Workspace registration without deleting its directory or Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ + delete(request: WorkspaceDeleteRequest): Promise { + return this.enqueue(async () => { + if (!await this.ctx.workspaceRegistry.delete(WorkspaceId(request.workspaceId))) { + throw workspaceNotFound(request.workspaceId) + } + return { deleted: true } + }) + } + + /** + * Move one Workspace within the durable registry order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ + async insertBefore(request: WorkspaceInsertBeforeRequest): Promise { + try { + const workspaceIds = await this.ctx.workspaceRegistry.insertBefore( + WorkspaceId(request.workspaceId), + request.beforeWorkspaceId === undefined + ? undefined + : WorkspaceId(request.beforeWorkspaceId), + ) + return { workspaceIds: [...workspaceIds] } + } catch (error) { + if (!(error instanceof WorkspaceOrderInvalidError)) throw error + throw workspaceNotFound(error.workspaceId) + } + } + + /** + * Move one accounted Session within a Workspace's manual order. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ + async insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise { + const workspace = this.requireWorkspace(request.workspaceId) + try { + await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId) + } catch (error) { + if (!(error instanceof WorkspaceMoveInvalidError)) throw error + throw failure( + 'workspace-move-invalid', + error.message, + { + workspaceId: request.workspaceId, + sessionId: request.sessionId, + ...request.beforeSessionId === undefined + ? {} + : { beforeSessionId: request.beforeSessionId }, + }, + ) + } + return { workspace: workspaceView(workspace) } + } + + /** + * Add one known Session to the registry-global archive set. + * @param request - Session identity to archive. + * @returns the complete resulting archive set. + */ + async archiveSession(request: WorkspaceArchiveSessionRequest): Promise { + try { + await this.ctx.workspaceRegistry.archiveSession(request.sessionId) + } catch (error) { + if (!(error instanceof WorkspaceUnknownSessionError)) throw error + throw failure('session-not-found', error.message, { sessionId: request.sessionId }) + } + return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] } + } + + private requireWorkspace(workspaceId: WorkspaceId): Workspace { + const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId)) + if (workspace === undefined) throw workspaceNotFound(workspaceId) + return workspace + } + + private enqueue(operation: () => Promise): Promise { + const result = this.operationTail.then(operation) + this.operationTail = result.then(() => undefined, () => undefined) + return result + } +} + +function workspaceNotFound(workspaceId: WorkspaceId): TypertRemoteFailure { + return failure( + 'workspace-not-found', + `Workspace "${workspaceId}" not found`, + { workspaceId }, + ) +} + +function failure( + code: string, + message: string, + details: object, +): TypertRemoteFailure { + return new TypertRemoteFailure({ code, message, details }) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/api/workspace-controller/src/feed.ts b/packages/api/workspace-controller/src/feed.ts new file mode 100644 index 0000000000..dcb1598c59 --- /dev/null +++ b/packages/api/workspace-controller/src/feed.ts @@ -0,0 +1,184 @@ +/** Reconnect-safe Workspace baseline and increment producer. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' +import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' +import { + workspaceDomainState, + workspaceRecord, + WorkspaceId, +} from '@deepseek-ai/dsh-workspace' +import type { + WorkspaceBaseline, + WorkspaceFollowFrame, + WorkspaceView, +} from './types.ts' + +/** + * Project one authoritative Workspace entity into its Remote value. + * @param workspace - authoritative registry entity. + * @returns detached Workspace projection for Remote consumers. + */ +export function workspaceView(workspace: Workspace): WorkspaceView { + return { + workspaceId: workspace.id, + path: workspace.path, + title: workspace.title, + sessionIds: [...workspace.sessionIds], + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + } +} + +function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView { + const record: WorkspaceRecord = workspaceRecord.parse(value) + return { + workspaceId: WorkspaceId(workspaceId), + path: record.path, + title: record.title, + sessionIds: [...record.sessionIds], + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } +} + +/** Owns Workspace domain observation and all active follow generations. */ +export class WorkspaceFeed { + private readonly followers = new Set() + private knownIds: Set + private order: readonly string[] + private archived: readonly string[] + + /** @param ctx - Host context containing the authoritative Workspace registry. */ + constructor(private readonly ctx: Context) { + const baseline = ctx.workspaceRegistry.list() + this.knownIds = new Set(baseline.map(workspace => String(workspace.id))) + this.order = baseline.map(workspace => String(workspace.id)) + this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String) + ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) }) + ctx.effect(() => () => { + for (const follower of this.followers) follower.close() + this.followers.clear() + }, 'workspace-controller.feed') + } + + /** + * Read the complete current projection synchronously. + * @returns all active Workspaces and archived Session identities. + */ + baseline(): WorkspaceBaseline { + return { + items: this.ctx.workspaceRegistry.list().map(workspaceView), + archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds], + } + } + + /** + * Open one generation beginning with a complete baseline. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const follower = new WorkspaceFollower() + this.followers.add(follower) + try { + yield { type: 'baseline', value: this.baseline() } + yield* follower.read(signal) + } finally { + this.followers.delete(follower) + follower.close() + } + } + + private changed(change: DomainChanged): void { + if (change.domain !== 'workspace') return + if (change.table === '') { + if (change.operation !== 'put') return + const state = workspaceDomainState.parse(change.value) + const nextOrder = state.workspaceIds.map(String) + const orderChanged = !sameStrings(this.order, nextOrder) + for (const id of state.workspaceIds) { + if (this.knownIds.has(id)) continue + const workspace = this.ctx.workspaceRegistry.get(id) + if (workspace === undefined) { + throw new Error(`committed Workspace registry references missing Workspace "${id}"`) + } + this.knownIds.add(id) + this.publish({ type: 'upsert', workspace: workspaceView(workspace) }) + } + this.order = nextOrder + if (orderChanged) this.publish({ type: 'order', workspaceIds: [...state.workspaceIds] }) + const nextArchived = state.archivedSessionIds.map(String) + if (!sameStrings(this.archived, nextArchived)) { + this.archived = nextArchived + this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] }) + } + return + } + if (change.table !== 'workspaces') return + if (change.operation === 'deleted') { + if (!this.knownIds.delete(change.key)) return + this.publish({ type: 'remove', workspaceId: WorkspaceId(change.key) }) + return + } + if (!this.knownIds.has(change.key)) return + this.publish({ + type: 'upsert', + workspace: changedWorkspaceView(change.key, change.value), + }) + } + + private publish(frame: Exclude): void { + for (const follower of this.followers) follower.push(frame) + } +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +class WorkspaceFollower { + private readonly frames: WorkspaceFollowFrame[] = [] + private waiting: (() => void) | undefined + private closed = false + + push(frame: WorkspaceFollowFrame): void { + /* v8 ignore next -- closed followers are removed before later publication can reach them. */ + if (this.closed) return + this.frames.push(frame) + this.waiting?.() + } + + close(): void { + if (this.closed) return + this.closed = true + this.waiting?.() + } + + async *read(signal: AbortSignal): AsyncIterable { + while (!this.closed && !signal.aborted) { + const frame = this.frames.shift() + if (frame !== undefined) { + yield frame + continue + } + await this.wait(signal) + } + } + + private wait(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one read owns the sole installed wait callback. */ + if (this.waiting === finish) this.waiting = undefined + resolve() + } + this.waiting = finish + signal.addEventListener('abort', finish, { once: true }) + /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ + if (signal.aborted || this.closed || this.frames.length > 0) finish() + }) + } +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts new file mode 100644 index 0000000000..0fbd514cd5 --- /dev/null +++ b/packages/api/workspace-controller/src/index.ts @@ -0,0 +1,116 @@ +/** Host Workspace Remote owner: explicit commands and reconnect-safe state. */ + +import { Context } from '@deepseek-ai/cordis' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { WorkspaceCommands } from './commands.ts' +import { WorkspaceFeed } from './feed.ts' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceValue, +} from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host Workspace business API and Remote namespace owner. */ + workspaceController: WorkspaceController + } +} + +/** Host service backing the generated `ctx.remote.workspace` namespace. */ +export class WorkspaceController extends TypertRemoteService { + static inject = ['typert', 'workspaceRegistry'] + + private readonly commands: WorkspaceCommands + private readonly feed: WorkspaceFeed + + /** @param ctx - Host context containing the Workspace registry. */ + constructor(ctx: Context) { + super(ctx, 'workspaceController', { namespace: 'workspace' }) + this.commands = new WorkspaceCommands(ctx) + this.feed = new WorkspaceFeed(ctx) + } + + /** + * Create or idempotently resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ + @Remote('create') + create(request: WorkspaceCreateRequest): Promise { + return this.commands.create(request) + } + + /** + * Rename one Workspace to a unique non-blank title. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ + @Remote('rename') + rename(request: WorkspaceRenameRequest): Promise { + return this.commands.rename(request) + } + + /** + * Remove one Workspace registration while retaining files and Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ + @Remote('delete') + delete(request: WorkspaceDeleteRequest): Promise { + return this.commands.delete(request) + } + + /** + * Move one Workspace within the registry display order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ + @Remote('insertBefore') + insertBefore(request: WorkspaceInsertBeforeRequest): Promise { + return this.commands.insertBefore(request) + } + + /** + * Move one accounted Session within a Workspace. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ + @Remote('insertSessionBefore') + insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise { + return this.commands.insertSessionBefore(request) + } + + /** + * Hide one known Session from Workspace grouping surfaces. + * @param request - Session identity to archive. + * @returns the complete resulting archive set. + */ + @Remote('archiveSession') + archiveSession(request: WorkspaceArchiveSessionRequest): Promise { + return this.commands.archiveSession(request) + } + + /** + * Stream a complete Workspace baseline followed by ordered increments. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ + @Remote({ mode: 'stream' }) + follow(signal: AbortSignal): AsyncIterable { + return this.feed.follow(signal) + } +} + +export default WorkspaceController diff --git a/packages/api/workspace-controller/src/invariant.ts b/packages/api/workspace-controller/src/invariant.ts new file mode 100644 index 0000000000..1e2835db0e --- /dev/null +++ b/packages/api/workspace-controller/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-workspace-controller/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-api-workspace-controller' + +/** Cordis companion plugin name. */ +export const name = 'api-workspace-controller-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: Workspace Registry owns persistence; every stream generation is a full projection. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts new file mode 100644 index 0000000000..f530e2ef12 --- /dev/null +++ b/packages/api/workspace-controller/src/types.ts @@ -0,0 +1,122 @@ +/** Browser-safe request, result, and state-stream vocabulary for Workspace Remote. */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' + +export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' + +/** One durable Workspace projected for browser consumers. */ +export interface WorkspaceView { + readonly workspaceId: WorkspaceId + /** Canonical host directory path. */ + readonly path: string + /** User-visible title. */ + readonly title: string + /** Sessions accounted to this Workspace in manual order. */ + readonly sessionIds: readonly SessionId[] + /** ISO-8601 creation instant. */ + readonly createdAt: string + /** ISO-8601 last-mutation instant. */ + readonly updatedAt: string +} + +/** Stable Workspace failure details returned by unary methods. */ +export interface WorkspaceErrorDetailsMap { + 'bad-request': Record + 'workspace-invalid-path': { readonly path: string } + 'workspace-not-found': { readonly workspaceId: WorkspaceId } + 'workspace-name-conflict': { readonly name: string } + 'workspace-move-invalid': { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId + } + 'session-not-found': { readonly sessionId: SessionId } +} + +/** Workspace business failure returned without throwing a carrier error. */ +export type WorkspaceError = { + [Code in keyof WorkspaceErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: WorkspaceErrorDetailsMap[Code] + } +}[keyof WorkspaceErrorDetailsMap] + +/** Existing directory requested for Workspace adoption. */ +export interface WorkspaceCreateRequest { + readonly path: string +} + +/** Created or previously registered Workspace. */ +export interface WorkspaceCreateValue { + readonly workspace: WorkspaceView + readonly created: boolean +} + +/** Workspace title mutation. */ +export interface WorkspaceRenameRequest { + readonly workspaceId: WorkspaceId + readonly title: string +} + +/** Workspace mutation returning the complete changed row. */ +export interface WorkspaceValue { + readonly workspace: WorkspaceView +} + +/** Workspace registration deletion. */ +export interface WorkspaceDeleteRequest { + readonly workspaceId: WorkspaceId +} + +/** Receipt after one Workspace registration is deleted. */ +export interface WorkspaceDeleteValue { + readonly deleted: true +} + +/** DOM-insertBefore-like Workspace order mutation. */ +export interface WorkspaceInsertBeforeRequest { + readonly workspaceId: WorkspaceId + readonly beforeWorkspaceId?: WorkspaceId +} + +/** Complete Workspace registry order after a mutation. */ +export interface WorkspaceOrderValue { + readonly workspaceIds: readonly WorkspaceId[] +} + +/** DOM-insertBefore-like Session membership order mutation. */ +export interface WorkspaceInsertSessionBeforeRequest { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId +} + +/** Session requested for archival from Workspace grouping surfaces. */ +export interface WorkspaceArchiveSessionRequest { + readonly sessionId: SessionId +} + +/** Complete archived Session set after a mutation. */ +export interface WorkspaceArchiveValue { + readonly archivedSessionIds: readonly SessionId[] +} + +/** Complete reconnect baseline for Workspace browser state. */ +export interface WorkspaceBaseline { + readonly items: readonly WorkspaceView[] + readonly archivedSessionIds: readonly SessionId[] +} + +/** One ordered Workspace change after a generation's baseline. */ +export type WorkspaceFollowIncrement = + | { readonly type: 'upsert'; readonly workspace: WorkspaceView } + | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } + | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + +/** Workspace state stream; every generation starts with exactly one baseline. */ +export type WorkspaceFollowFrame = + | { readonly type: 'baseline'; readonly value: WorkspaceBaseline } + | WorkspaceFollowIncrement diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts new file mode 100644 index 0000000000..572f5b8f9c --- /dev/null +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -0,0 +1,384 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ClientWorkspaceModel, type WorkspaceRemote, +} from '../src/client/index.ts' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceValue, + WorkspaceError, + WorkspaceId, + WorkspaceView, +} from '../src/types.ts' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +const sid = (id: string): SessionId => id as SessionId +const wid = (id: string): WorkspaceId => id as WorkspaceId + +function workspace( + id: string, + sessionIds: readonly SessionId[] = [], + updatedAt = '2026-01-01T00:00:00.000Z', +): WorkspaceView { + return { + workspaceId: wid(id), + path: `/w/${id}`, + title: id, + sessionIds, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt, + } +} + +function remoteOk(value: T): RemoteResult { + return { ok: true, value } +} + +function workspaceError(error: WorkspaceError): RemoteResult { + return { ok: false, error } +} + +interface Deferred { + readonly promise: Promise + resolve(value: T): void + reject(error: unknown): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + return { promise, reject, resolve } +} + +class FakeWorkspaceRemote implements WorkspaceRemote { + readonly calls: Array<{ readonly method: string; readonly request: unknown }> = [] + onCreate: (request: WorkspaceCreateRequest) => Promise> = request => + Promise.resolve(remoteOk({ workspace: workspace(request.path.split('/').pop() ?? 'workspace'), created: true })) + onRename: (request: WorkspaceRenameRequest) => Promise> = request => + Promise.resolve(remoteOk({ workspace: { ...workspace(String(request.workspaceId)), title: request.title } })) + onDelete: (_request: WorkspaceDeleteRequest) => Promise> = () => + Promise.resolve(remoteOk({ deleted: true })) + onInsertBefore: ( + request: WorkspaceInsertBeforeRequest, + ) => Promise> = request => + Promise.resolve(remoteOk({ workspaceIds: [request.workspaceId] })) + onInsertSessionBefore: ( + request: WorkspaceInsertSessionBeforeRequest, + ) => Promise> = request => Promise.resolve(remoteOk({ + workspace: workspace(String(request.workspaceId), [request.sessionId]), + })) + onArchiveSession: ( + request: WorkspaceArchiveSessionRequest, + ) => Promise> = request => + Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] })) + + create(request: WorkspaceCreateRequest): Promise> { + this.record('create', request) + return this.onCreate(request) + } + + rename(request: WorkspaceRenameRequest): Promise> { + this.record('rename', request) + return this.onRename(request) + } + + delete(request: WorkspaceDeleteRequest): Promise> { + this.record('delete', request) + return this.onDelete(request) + } + + insertBefore(request: WorkspaceInsertBeforeRequest): Promise> { + this.record('insertBefore', request) + return this.onInsertBefore(request) + } + + insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise> { + this.record('insertSessionBefore', request) + return this.onInsertSessionBefore(request) + } + + archiveSession(request: WorkspaceArchiveSessionRequest): Promise> { + this.record('archiveSession', request) + return this.onArchiveSession(request) + } + + async *follow(_signal?: AbortSignal): AsyncGenerator {} + + private record(method: string, request: unknown): void { + this.calls.push({ method, request }) + } +} + +function modelFor(remote = new FakeWorkspaceRemote()): ClientWorkspaceModel { + return new ClientWorkspaceModel(remote) +} + +function baseline( + model: ClientWorkspaceModel, + items: readonly WorkspaceView[] = [], + archivedSessionIds: readonly SessionId[] = [], +): void { + model.replaceBaseline({ items, archivedSessionIds }) +} + +describe('ClientWorkspaceModel', () => { + it('replaces reconnect state and applies ordered increments', () => { + const model = modelFor() + expect(model.getSnapshot()).toMatchObject({ phase: 'pending', state: 'loading' }) + baseline(model, [workspace('old'), workspace('kept')]) + model.upsertView(workspace('new')) + model.replaceOrder([wid('kept'), wid('new'), wid('old')]) + model.replaceArchived([sid('hidden')]) + model.removeView(wid('old')) + expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', archivedSessionIds: ['hidden'] }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept', 'new']) + + baseline(model, [workspace('fresh')]) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['fresh']) + expect(model.getSnapshot().archivedSessionIds).toEqual([]) + }) + + it('keeps the last baseline during retry and exposes a terminal stream failure', () => { + const model = modelFor() + baseline(model, [workspace('visible')]) + model.handleCarrierFailure() + expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'loading', error: null }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['visible']) + model.handleStreamFailure(new Error('wire down')) + expect(model.getSnapshot()).toMatchObject({ + phase: 'ready', state: 'error', error: { code: 'internal', message: 'wire down' }, + }) + model.handleStreamFailure('plain failure') + expect(model.getSnapshot().error?.message).toBe('plain failure') + baseline(model, [workspace('restored')]) + expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null }) + }) + + it('creates by path, prepends the returned row, and folds rejected calls', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + remote.onCreate = request => Promise.resolve(remoteOk({ + workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'), + created: request.path === '/w/created', + })) + await expect(model.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) + expect(remote.calls).toContainEqual({ method: 'create', request: { path: '/w/created' } }) + expect(model.getSnapshot().items[0]?.workspaceId).toBe('created') + + remote.onCreate = () => Promise.reject(new Error('create transport')) + await expect(model.create({ path: '/w/existing' })).resolves.toMatchObject({ + ok: false, error: { code: 'internal', message: 'create transport' }, + }) + }) + + it('lets newer stream order outrank unary echoes and rolls failures back', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('one'), workspace('two'), workspace('three')]) + + const gate = deferred>() + remote.onInsertBefore = () => gate.promise + const pending = model.insertBefore(wid('three'), wid('one')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) + model.replaceOrder([wid('one'), wid('three'), wid('two')]) + gate.resolve(remoteOk({ workspaceIds: [wid('three'), wid('one'), wid('two')] })) + await pending + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + + remote.onInsertBefore = () => Promise.resolve(workspaceError({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('three') }, + })) + const rejected = model.insertBefore(wid('three')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) + await expect(rejected).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + + remote.onInsertBefore = () => Promise.reject(new Error('transport down')) + const disconnected = model.insertBefore(wid('three'), wid('one')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) + await expect(disconnected).rejects.toThrow('transport down') + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + }) + + it('keeps a newer optimistic reorder when an older transport call rejects', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('one'), workspace('two'), workspace('three')]) + const firstGate = deferred>() + const secondGate = deferred>() + let request = 0 + remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise + + const first = model.insertBefore(wid('three'), wid('one')) + const second = model.insertBefore(wid('two'), wid('three')) + firstGate.reject(new Error('first transport failed')) + await expect(first).rejects.toThrow('first transport failed') + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) + secondGate.resolve(remoteOk({ workspaceIds: [wid('two'), wid('three'), wid('one')] })) + await expect(second).resolves.toMatchObject({ ok: true }) + }) + + it('rolls overlapping rejected reorders back to the last Host order', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('one'), workspace('two'), workspace('three')]) + const firstGate = deferred>() + const secondGate = deferred>() + let request = 0 + remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise + + const first = model.insertBefore(wid('three'), wid('one')) + const second = model.insertBefore(wid('two'), wid('three')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) + firstGate.resolve(workspaceError({ + code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: wid('three') }, + })) + await expect(first).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) + secondGate.resolve(workspaceError({ + code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: wid('two') }, + })) + await expect(second).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) + }) + + it('retains removal tombstones across later baselines', () => { + const model = modelFor() + baseline(model, [workspace('gone'), workspace('kept')]) + model.removeView(wid('gone')) + model.removeView(wid('gone')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + baseline(model, [workspace('gone')]) + expect(model.getSnapshot().items).toEqual([]) + }) + + it('does not let delayed unary data resurrect a removed Workspace', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('gone')]) + const gate = deferred>() + remote.onRename = () => gate.promise + const rename = model.rename(wid('gone'), 'late') + model.removeView(wid('gone')) + gate.resolve(remoteOk({ workspace: { ...workspace('gone'), title: 'late' } })) + await expect(rename).resolves.toMatchObject({ ok: true }) + expect(model.getSnapshot().items).toEqual([]) + }) + + it('applies Workspace mutation echoes and leaves failed results unchanged', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('one', [sid('first'), sid('second')])], [sid('archived')]) + + remote.onRename = () => Promise.resolve(workspaceError({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') }, + })) + await expect(model.rename(wid('one'), 'ignored')).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items[0]?.title).toBe('one') + + remote.onDelete = () => Promise.resolve(workspaceError({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') }, + })) + await expect(model.delete(wid('one'))).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items).toHaveLength(1) + + remote.onInsertSessionBefore = request => Promise.resolve(remoteOk({ + workspace: workspace('one', [request.sessionId, sid('first')], '2026-02-01T00:00:00.000Z'), + })) + await expect(model.insertSessionBefore(wid('one'), sid('second'), sid('first'))) + .resolves.toMatchObject({ ok: true }) + expect(remote.calls).toContainEqual({ + method: 'insertSessionBefore', + request: { workspaceId: 'one', sessionId: 'second', beforeSessionId: 'first' }, + }) + + remote.onInsertSessionBefore = () => Promise.resolve(workspaceError({ + code: 'workspace-move-invalid', + message: 'invalid move', + details: { workspaceId: wid('one'), sessionId: sid('second') }, + })) + await expect(model.insertSessionBefore(wid('one'), sid('second'))) + .resolves.toMatchObject({ ok: false }) + expect(remote.calls).toContainEqual({ + method: 'insertSessionBefore', + request: { workspaceId: 'one', sessionId: 'second' }, + }) + + remote.onArchiveSession = () => Promise.resolve(workspaceError({ + code: 'session-not-found', message: 'missing', details: { sessionId: sid('missing') }, + })) + await expect(model.archiveSession(sid('missing'))).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().archivedSessionIds).toEqual(['archived']) + remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] })) + await expect(model.archiveSession(sid('fresh'))).resolves.toMatchObject({ ok: true }) + expect(model.getSnapshot().archivedSessionIds).toEqual(['fresh']) + }) + + it('keeps the newest row and places Workspaces missing from partial orders last', async () => { + const model = modelFor() + baseline(model, [ + workspace('one', [], '2026-02-01T00:00:00.000Z'), + workspace('two'), + ]) + model.upsertView(workspace('one', [], '2025-12-01T00:00:00.000Z')) + expect(model.getSnapshot().items[0]?.updatedAt).toBe('2026-02-01T00:00:00.000Z') + model.upsertView(workspace('one', [sid('new')], '2026-03-01T00:00:00.000Z')) + expect(model.getSnapshot().items[0]?.sessionIds).toEqual(['new']) + + model.replaceOrder([wid('one')]) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two']) + model.replaceOrder([wid('two')]) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one']) + model.replaceOrder([wid('one')]) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two']) + + await expect(model.insertBefore(wid('one'), wid('one'))).resolves.toMatchObject({ ok: true }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two']) + }) + + it('notifies subscribers and cancels a queued notification after an immediate delete echo', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('gone')]) + await Promise.resolve() + const listener = vi.fn() + const unsubscribe = model.subscribe(listener) + + const deletion = model.delete(wid('gone')) + model.removeView(wid('gone')) + await expect(deletion).resolves.toMatchObject({ ok: true }) + expect(listener).toHaveBeenCalledOnce() + await Promise.resolve() + expect(listener).toHaveBeenCalledOnce() + + unsubscribe() + model.handleCarrierFailure() + await Promise.resolve() + expect(listener).toHaveBeenCalledOnce() + }) + + it('removes from a unary delete echo before the operation resolves', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('gone')]) + await expect(model.delete(wid('gone'))).resolves.toMatchObject({ ok: true }) + expect(remote.calls).toContainEqual({ method: 'delete', request: { workspaceId: 'gone' } }) + expect(model.getSnapshot().items).toEqual([]) + model.removeView(wid('gone')) + expect(model.getSnapshot().items).toEqual([]) + }) +}) diff --git a/packages/client/runtime/tests/path.client.spec.ts b/packages/api/workspace-controller/tests/path.client.spec.ts similarity index 98% rename from packages/client/runtime/tests/path.client.spec.ts rename to packages/api/workspace-controller/tests/path.client.spec.ts index 455df0d112..48a7d63b85 100644 --- a/packages/client/runtime/tests/path.client.spec.ts +++ b/packages/api/workspace-controller/tests/path.client.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/workspaces/path.ts' +import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/path.ts' describe('abbreviateHomePath', () => { it('collapses a POSIX home and its descendants', () => { diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts new file mode 100644 index 0000000000..d71cfba44e --- /dev/null +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -0,0 +1,496 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { + RemoteStream, + RemoteStreamCarrierError, + type RemoteStreamOptions, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import * as WorkspaceClientPlugin from '../src/client/index.ts' +import { + ClientWorkspaceModel, + createWorkspaceStateStream, + WorkspaceController, + WorkspaceCreateError, + type WorkspaceFollowSink, + type WorkspaceRemote, +} from '../src/client/index.ts' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceError, + WorkspaceId, + WorkspaceValue, + WorkspaceView, +} from '../src/types.ts' + +const AVAILABLE_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + +function workspaceClient( + remote: WorkspaceRemote, + connection: Pick = AVAILABLE_CONNECTION, +) { + return { + workspace: remote, + $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), + } +} + +interface Generation { + readonly frames: readonly WorkspaceFollowFrame[] + readonly error?: unknown + readonly hold?: boolean + readonly afterAbort?: () => void + readonly afterAbortError?: unknown +} + +const baseline = (id?: string): Extract => ({ + type: 'baseline', + value: { + items: id === undefined ? [] : [{ + workspaceId: id as never, + path: `/work/${id}`, + title: id, + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }], + archivedSessionIds: [], + }, +}) + +const wid = (id: string): WorkspaceId => id as WorkspaceId +const sid = (id: string): SessionId => SessionId(id) + +function workspace(id: string, overrides: Partial = {}): WorkspaceView { + return { + workspaceId: wid(id), + path: `/work/${id}`, + title: id, + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +function remoteOk(value: T): RemoteResult { + return { ok: true, value } +} + +function remoteFailure(error: WorkspaceError): RemoteResult { + return { ok: false, error } +} + +function accepts(overrides: Partial = {}): WorkspaceFollowSink { + const ignore = (): void => {} + return { + replaceBaseline: ignore, + upsertView: ignore, + removeView: ignore, + replaceOrder: ignore, + replaceArchived: ignore, + ...overrides, + } +} + +class ScriptedWorkspaceRemote implements WorkspaceRemote { + readonly signals: AbortSignal[] = [] + calls = 0 + + constructor(private readonly generations: readonly Generation[]) {} + + create(_request: WorkspaceCreateRequest): Promise> { + throw new Error('unused') + } + + rename(_request: WorkspaceRenameRequest): Promise> { + throw new Error('unused') + } + + delete(_request: WorkspaceDeleteRequest): Promise> { + throw new Error('unused') + } + + insertBefore(_request: WorkspaceInsertBeforeRequest): Promise> { + throw new Error('unused') + } + + insertSessionBefore(_request: WorkspaceInsertSessionBeforeRequest): Promise> { + throw new Error('unused') + } + + archiveSession(_request: WorkspaceArchiveSessionRequest): Promise> { + throw new Error('unused') + } + + async *follow(signal = new AbortController().signal): AsyncIterable { + const generation = this.generations[this.calls++] + if (generation === undefined) throw new Error('no scripted Workspace generation') + this.signals.push(signal) + for (const frame of generation.frames) yield frame + if (generation.error !== undefined) throw generation.error + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + generation.afterAbort?.() + if (generation.afterAbortError !== undefined) throw generation.afterAbortError + } + } +} + +class CommandWorkspaceRemote implements WorkspaceRemote { + readonly create = vi.fn(request => Promise.resolve(remoteOk({ + workspace: workspace('created', { path: request.path }), + created: true, + }))) + + readonly rename = vi.fn(request => Promise.resolve(remoteOk({ + workspace: workspace(String(request.workspaceId), { title: request.title }), + }))) + + readonly delete = vi.fn(() => Promise.resolve(remoteOk({ deleted: true }))) + + readonly insertBefore = vi.fn(request => Promise.resolve(remoteOk({ + workspaceIds: [request.workspaceId], + }))) + + readonly insertSessionBefore = vi.fn(request => Promise.resolve(remoteOk({ + workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }), + }))) + + readonly archiveSession = vi.fn(request => Promise.resolve(remoteOk({ + archivedSessionIds: [request.sessionId], + }))) + + async *follow(_signal?: AbortSignal): AsyncIterable {} +} + +async function waitFor(check: () => void): Promise { + for (let attempt = 0; attempt < 40; attempt++) { + try { + check() + return + } catch { + await Promise.resolve() + } + } + check() +} + +function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { + const connection: ConnectionHandle = { + api: {} as ConnectionHandle['api'], + isLoopback: true, + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', + cwd: '/fixture', + attachedSessions: 0, + home: '/home/fixture', + canOpenPath: true, + }), + subscribe: () => () => {}, + }, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, + registerGenerationSource: () => () => {}, + start: () => ({ stop: () => {} }), + } + ctx.reflect.provide('connection', connection) + ctx.reflect.provide('remote', workspaceClient(remote, connection)) + ctx.reflect.provide('remote.workspace', remote) +} + +describe('Workspace Controller Client apply', () => { + it('provides the Workspace service and stops its follow generation with the plugin fiber', async () => { + const ctx = new Context() + const remote = new ScriptedWorkspaceRemote([{ frames: [baseline('mounted')], hold: true }]) + provideClientServices(ctx, remote) + const fiber = ctx.plugin(WorkspaceClientPlugin) + await fiber + await waitFor(() => { + expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + phase: 'ready', + state: 'idle', + items: [{ workspaceId: 'mounted' }], + }) + }) + + await fiber.dispose() + + expect(remote.signals[0]?.aborted).toBe(true) + expect(ctx.get('workspaces')).toBeUndefined() + }) + + it('marks carrier loss while retrying and publishes a later protocol failure', async () => { + const ctx = new Context() + const remote = new ScriptedWorkspaceRemote([ + { + frames: [baseline('old')], + error: new RemoteStreamCarrierError('generation lost'), + }, + { frames: [baseline('fresh'), baseline('duplicate')] }, + ]) + provideClientServices(ctx, remote) + const carrierFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleCarrierFailure') + const streamFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleStreamFailure') + const fiber = ctx.plugin(WorkspaceClientPlugin) + await fiber + await waitFor(() => { + expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + phase: 'ready', + state: 'error', + items: [{ workspaceId: 'fresh' }], + error: { code: 'internal', message: 'Workspace state stream emitted more than one opening snapshot' }, + }) + }) + + expect(carrierFailure).toHaveBeenCalledOnce() + expect(streamFailure).toHaveBeenCalledOnce() + await fiber.dispose() + }) +}) + +describe('Workspace state stream', () => { + it('delivers one baseline followed by increments', async () => { + const opening = baseline('one') + const workspace = opening.value.items[0]! + const remote = new ScriptedWorkspaceRemote([{ + frames: [ + opening, + { type: 'upsert', workspace }, + { type: 'remove', workspaceId: workspace.workspaceId }, + { type: 'order', workspaceIds: [workspace.workspaceId] }, + { type: 'archived', archivedSessionIds: ['session-one' as never] }, + ], + hold: true, + }]) + const replaceBaseline = vi.fn() + const upsertView = vi.fn() + const removeView = vi.fn() + const replaceOrder = vi.fn() + const replaceArchived = vi.fn() + const accept = accepts({ + replaceBaseline, + upsertView, + removeView, + replaceOrder, + replaceArchived, + }) + const stream = createWorkspaceStateStream(workspaceClient(remote), { + accept, + failed: vi.fn(), + }) + + stream.start() + stream.start() + await vi.waitFor(() => { expect(replaceArchived).toHaveBeenCalledOnce() }) + + expect(replaceBaseline).toHaveBeenCalledWith(opening.value) + expect(upsertView).toHaveBeenCalledWith(workspace) + expect(removeView).toHaveBeenCalledWith(workspace.workspaceId) + expect(replaceOrder).toHaveBeenCalledWith([workspace.workspaceId]) + expect(replaceArchived).toHaveBeenCalledWith(['session-one']) + await stream.dispose() + expect(remote.signals[0]?.aborted).toBe(true) + }) + + it('retains the old state across carrier loss and applies the replacement baseline', async () => { + const carrier = new RemoteStreamCarrierError('socket lost') + const remote = new ScriptedWorkspaceRemote([ + { frames: [baseline('old')], error: carrier }, + { frames: [baseline('fresh')], hold: true }, + ]) + const replaceBaseline = vi.fn() + const carrierFailed = vi.fn() + const failed = vi.fn() + const stream = createWorkspaceStateStream(workspaceClient(remote), { + accept: accepts({ replaceBaseline }), + carrierFailed, + failed, + }) + + stream.start() + await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) }) + + expect(replaceBaseline.mock.calls.map(([value]) => value.items[0]?.title)).toEqual(['old', 'fresh']) + expect(carrierFailed).toHaveBeenCalledWith(carrier) + expect(failed).not.toHaveBeenCalled() + await stream.dispose() + }) + + it('classifies a normal end after the opening baseline as carrier loss', async () => { + const remote = new ScriptedWorkspaceRemote([ + { frames: [baseline('old')] }, + { frames: [baseline('fresh')], hold: true }, + ]) + const replaceBaseline = vi.fn() + const carrierFailed = vi.fn() + const stream = createWorkspaceStateStream(workspaceClient(remote), { + accept: accepts({ replaceBaseline }), + carrierFailed, + failed: vi.fn(), + }) + + stream.start() + await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) }) + expect(carrierFailed.mock.calls[0]?.[0]).toMatchObject({ + message: 'Workspace state stream ended without a terminal result', + }) + await stream.dispose() + }) + + it('suppresses callback failure after disposal begins', async () => { + const failed = vi.fn() + let closing: Promise | undefined + const stream = createWorkspaceStateStream( + workspaceClient(new ScriptedWorkspaceRemote([{ frames: [baseline()] }])), + { + accept: accepts({ + replaceBaseline: () => { + closing = stream.dispose() + throw new Error('disposed callback') + }, + }), + failed, + }, + ) + + stream.start() + await vi.waitFor(() => { expect(closing).toBeDefined() }) + await closing + expect(failed).not.toHaveBeenCalled() + }) + + it.each([ + { + name: 'an increment before the baseline', + frames: [{ type: 'remove', workspaceId: 'one' as never }] as WorkspaceFollowFrame[], + message: 'update before its opening snapshot', + }, + { + name: 'a duplicate baseline', + frames: [baseline(), baseline()] as WorkspaceFollowFrame[], + message: 'more than one opening snapshot', + }, + { + name: 'a normal end before the baseline', + frames: [] as WorkspaceFollowFrame[], + message: 'ended before its opening snapshot', + }, + ])('reports $name as a terminal failure', async ({ frames, message }) => { + const failed = vi.fn() + const stream = createWorkspaceStateStream( + workspaceClient(new ScriptedWorkspaceRemote([{ frames }])), + { accept: accepts(), failed }, + ) + + stream.start() + await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() }) + const failure: unknown = failed.mock.calls[0]?.[0] + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error('expected Workspace stream failure') + expect(failure.message).toContain(message) + await stream.dispose() + }) + + it('restarts a live generation without reporting cancellation as failure', async () => { + const remote = new ScriptedWorkspaceRemote([ + { frames: [baseline('first')], hold: true }, + { frames: [baseline('second')], hold: true }, + ]) + const replaceBaseline = vi.fn() + const failed = vi.fn() + const stream = createWorkspaceStateStream(workspaceClient(remote), { + accept: accepts({ replaceBaseline }), + failed, + }) + + stream.start() + await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledOnce() }) + stream.restart() + await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) }) + expect(failed).not.toHaveBeenCalled() + await stream.dispose() + }) +}) + +describe('WorkspaceController', () => { + it('publishes the model source and exposes successful Workspace commands', async () => { + const remote = new CommandWorkspaceRemote() + const model = new ClientWorkspaceModel(remote) + model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [] }) + const controller = new WorkspaceController(new Context(), model) + + expect(controller.list).toBe(model) + await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' }) + await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' }) + await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined() + await expect(controller.insertSessionBefore(wid('one'), sid('session'))).resolves.toMatchObject({ + sessionIds: ['session'], + }) + await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined() + await expect(controller.delete(wid('one'))).resolves.toBeUndefined() + }) + + it('maps generated business failures to the command facade errors', async () => { + const remote = new CommandWorkspaceRemote() + const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote)) + const missingWorkspace: WorkspaceError = { + code: 'workspace-not-found', + message: 'gone', + details: { workspaceId: wid('missing') }, + } + const missingSession: WorkspaceError = { + code: 'session-not-found', + message: 'missing session', + details: { sessionId: sid('session') }, + } + + remote.create.mockResolvedValueOnce(remoteFailure({ + code: 'workspace-invalid-path', + message: 'missing path', + details: { path: '/missing' }, + })) + const create = controller.create({ path: '/missing' }) + await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) + await expect(create).rejects.toThrow('workspace-invalid-path: missing path') + + remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace-not-found: gone') + remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace-not-found: gone') + remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace-not-found: gone') + remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession)) + await expect(controller.archiveSession(sid('session'))).rejects.toThrow('workspace session archive failed: session-not-found: missing session') + remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure({ + code: 'workspace-move-invalid', + message: 'invalid move', + details: { workspaceId: wid('missing'), sessionId: sid('session') }, + })) + await expect(controller.insertSessionBefore(wid('missing'), sid('session'))) + .rejects.toThrow('workspace move failed: workspace-move-invalid: invalid move') + }) +}) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts new file mode 100644 index 0000000000..88e2e65964 --- /dev/null +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -0,0 +1,333 @@ +import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import Storage from '@deepseek-ai/dsh-storage' +import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import WorkspaceController from '../src/index.ts' +import { WorkspaceFeed } from '../src/feed.ts' +import type { WorkspaceFollowFrame } from '../src/types.ts' +import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +interface Deferred { + readonly promise: Promise + resolve(value: T): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} + +async function harness() { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-'))) + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend()) + const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', storageDomain) + ctx.provide('storageDomain', storageDomain) + ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) + await ctx.plugin(WorkspaceRegistry) + const dispose = (): void => {} + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + } as never) + const controller = new WorkspaceController(ctx) + return { controller, ctx, root, storageDomain } +} + +function stageDir(root: string, name: string): string { + const path = join(root, name) + mkdirSync(path, { recursive: true }) + return path +} + +async function nextFrame( + iterator: AsyncIterator, +): Promise { + const next = await iterator.next() + if (next.done === true) throw new Error('Workspace stream ended before the expected frame') + return next.value +} + +describe('WorkspaceController commands', () => { + it('serializes concurrent path adoption and preserves an existing title', async () => { + const { controller, root } = await harness() + const path = stageDir(root, 'alpha') + const results = await Promise.all([ + controller.create({ path }), + controller.create({ path }), + ]) + const created = results.find(result => result.created) + const resolved = results.find(result => !result.created) + expect(created).toMatchObject({ workspace: { path, title: 'alpha' } }) + expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId) + + const workspaceId = created?.workspace.workspaceId + if (workspaceId === undefined) throw new Error('fixture did not create a Workspace') + await controller.rename({ workspaceId, title: 'renamed' }) + await expect(controller.create({ path })).resolves.toMatchObject({ + created: false, + workspace: { workspaceId, title: 'renamed' }, + }) + }) + + it('maps invalid paths, blank names, conflicts, and unknown ids to stable failures', async () => { + const { controller, root } = await harness() + const first = await controller.create({ path: stageDir(root, 'first') }) + const second = await controller.create({ path: stageDir(root, 'second') }) + + await expect(controller.create({ path: join(root, 'missing') })).rejects.toMatchObject({ + failure: { code: 'workspace-invalid-path', details: { path: join(root, 'missing') } }, + }) + expect(existsSync(join(root, 'missing'))).toBe(false) + await expect(controller.rename({ workspaceId: first.workspace.workspaceId, title: ' ' })) + .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'occupied' }) + await expect(controller.rename({ workspaceId: second.workspace.workspaceId, title: ' occupied ' })) + .rejects.toMatchObject({ failure: { code: 'workspace-name-conflict' } }) + await expect(controller.delete({ workspaceId: 'missing' as WorkspaceId })) + .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + }) + + it('preserves Remote failures and propagates unexpected registry failures', async () => { + const { controller, ctx, root } = await harness() + const remoteFailure = new TypertRemoteFailure({ + code: 'fixture-failure', + message: 'already mapped', + details: {}, + }) + const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath') + .mockRejectedValueOnce(remoteFailure) + .mockRejectedValueOnce('plain failure') + await expect(controller.create({ path: stageDir(root, 'remote-failure') })) + .rejects.toBe(remoteFailure) + const plainFailure = controller.create({ path: stageDir(root, 'plain-failure') }) + await expect(plainFailure).rejects.toMatchObject({ + failure: { code: 'workspace-invalid-path' }, + }) + await expect(plainFailure).rejects.toThrow('plain failure') + resolveByPath.mockRestore() + + const created = await controller.create({ path: stageDir(root, 'created') }) + const workspace = ctx.workspaceRegistry.get(created.workspace.workspaceId) + if (workspace === undefined) throw new Error('fixture Workspace disappeared') + + const orderFailure = new Error('order storage failed') + vi.spyOn(ctx.workspaceRegistry, 'insertBefore').mockRejectedValueOnce(orderFailure) + await expect(controller.insertBefore({ workspaceId: created.workspace.workspaceId })) + .rejects.toBe(orderFailure) + + const moveFailure = new Error('membership storage failed') + vi.spyOn(workspace, 'insertSessionBefore').mockRejectedValueOnce(moveFailure) + await expect(controller.insertSessionBefore({ + workspaceId: created.workspace.workspaceId, + sessionId: SessionId('session'), + })).rejects.toBe(moveFailure) + + const archiveFailure = new Error('archive storage failed') + vi.spyOn(ctx.workspaceRegistry, 'archiveSession').mockRejectedValueOnce(archiveFailure) + await expect(controller.archiveSession({ sessionId: SessionId('session') })) + .rejects.toBe(archiveFailure) + }) + + it('resolves queued Workspace identities when their operation starts', async () => { + const { controller, ctx, root } = await harness() + const target = await controller.create({ path: stageDir(root, 'target') }) + const blockerPath = stageDir(root, 'blocker') + const gate = deferred() + const originalResolveByPath = ctx.workspaceRegistry.resolveByPath.bind(ctx.workspaceRegistry) + const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath') + resolveByPath.mockImplementationOnce(async (path) => { + await gate.promise + return originalResolveByPath(path) + }) + + const blocker = controller.create({ path: blockerPath }) + const deletion = controller.delete({ workspaceId: target.workspace.workspaceId }) + const staleRename = controller.rename({ + workspaceId: target.workspace.workspaceId, + title: 'must-not-land', + }) + gate.resolve(undefined) + await blocker + await expect(deletion).resolves.toEqual({ deleted: true }) + await expect(staleRename).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + }) + + it('reorders Workspaces and Sessions and archives only known Sessions', async () => { + const { controller, ctx, root } = await harness() + const first = await controller.create({ path: stageDir(root, 'first') }) + const second = await controller.create({ path: stageDir(root, 'second') }) + await expect(controller.insertBefore({ + workspaceId: first.workspace.workspaceId, + beforeWorkspaceId: second.workspace.workspaceId, + })).resolves.toEqual({ + workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId], + }) + await expect(controller.insertBefore({ workspaceId: 'missing' as WorkspaceId })) + .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + + const session = ctx.sessions.create(SessionId('session-one'), { + meta: { cwd: first.workspace.path }, + }) + const workspace = ctx.workspaceRegistry.get(first.workspace.workspaceId) + if (workspace === undefined) throw new Error('fixture Workspace disappeared') + await workspace.attachSession(session.id) + await expect(controller.insertSessionBefore({ + workspaceId: first.workspace.workspaceId, + sessionId: session.id, + })).resolves.toMatchObject({ workspace: { sessionIds: [session.id] } }) + await expect(controller.insertSessionBefore({ + workspaceId: first.workspace.workspaceId, + sessionId: SessionId('missing-session'), + })).rejects.toMatchObject({ failure: { code: 'workspace-move-invalid' } }) + await expect(controller.insertSessionBefore({ + workspaceId: first.workspace.workspaceId, + sessionId: session.id, + beforeSessionId: SessionId('missing-anchor'), + })).rejects.toMatchObject({ + failure: { + code: 'workspace-move-invalid', + details: { beforeSessionId: 'missing-anchor' }, + }, + }) + await expect(controller.insertSessionBefore({ + workspaceId: 'missing' as WorkspaceId, + sessionId: session.id, + })).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + + await expect(controller.archiveSession({ sessionId: session.id })) + .resolves.toEqual({ archivedSessionIds: [session.id] }) + await expect(controller.archiveSession({ sessionId: SessionId('unknown') })) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + }) +}) + +describe('WorkspaceController follow', () => { + it('seeds a new feed from existing rows and rejects an inconsistent registry commit', async () => { + const { ctx, root } = await harness() + const existing = await ctx.workspaceRegistry.create(stageDir(root, 'existing')) + const feed = new WorkspaceFeed(ctx) + expect(feed.baseline()).toMatchObject({ + items: [{ workspaceId: existing.id }], + }) + + expect(() => { + ctx.emit('domain/changed', { + domain: 'workspace', + table: '', + key: '', + operation: 'put', + value: { + initialized: true, + workspaceIds: ['missing'], + archivedSessionIds: [], + }, + }) + }).toThrow('references missing Workspace "missing"') + }) + + it('starts with a complete baseline and emits committed increments in domain order', async () => { + const { controller, ctx, root } = await harness() + const abort = new AbortController() + const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]() + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'baseline', + value: { items: [], archivedSessionIds: [] }, + }) + + const first = await controller.create({ path: stageDir(root, 'first') }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ + type: 'upsert', workspace: { workspaceId: first.workspace.workspaceId }, + }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'order', workspaceIds: [first.workspace.workspaceId], + }) + await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'renamed' }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ + type: 'upsert', workspace: { title: 'renamed' }, + }) + + const second = await controller.create({ path: stageDir(root, 'second') }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ + type: 'upsert', workspace: { workspaceId: second.workspace.workspaceId }, + }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'order', workspaceIds: [second.workspace.workspaceId, first.workspace.workspaceId], + }) + await controller.insertBefore({ + workspaceId: first.workspace.workspaceId, + beforeWorkspaceId: second.workspace.workspaceId, + }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'order', + workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId], + }) + + const session = ctx.sessions.create(SessionId('archived'), { + meta: { cwd: first.workspace.path }, + }) + await controller.archiveSession({ sessionId: session.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'archived', archivedSessionIds: [session.id], + }) + await controller.delete({ workspaceId: second.workspace.workspaceId }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'order', workspaceIds: [first.workspace.workspaceId], + }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'remove', workspaceId: second.workspace.workspaceId, + }) + + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('ignores unrelated domain writes and closes active followers on disposal', async () => { + const { controller, ctx, root } = await harness() + const abort = new AbortController() + const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]() + await nextFrame(iterator) + ctx.emit('domain/changed', { + domain: 'other', table: 'records', key: 'x', operation: 'put', value: {}, + }) + ctx.emit('domain/changed', { + domain: 'workspace', table: '', key: '', operation: 'deleted', + }) + ctx.emit('domain/changed', { + domain: 'workspace', table: 'other', key: 'x', operation: 'put', value: {}, + }) + ctx.emit('domain/changed', { + domain: 'workspace', table: 'workspaces', key: 'unknown', operation: 'deleted', + }) + const pending = iterator.next() + const created = await controller.create({ path: stageDir(root, 'visible') }) + await expect(pending).resolves.toMatchObject({ value: { type: 'upsert' } }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { type: 'order', workspaceIds: [created.workspace.workspaceId] }, + }) + + const closing = iterator.next() + await ctx.fiber.dispose() + roots.splice(roots.indexOf(ctx), 1) + await expect(closing).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/api/workspace-controller/tsconfig.client.json b/packages/api/workspace-controller/tsconfig.client.json new file mode 100644 index 0000000000..e2f1eacd1c --- /dev/null +++ b/packages/api/workspace-controller/tsconfig.client.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/path.ts", + "src/client/service.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, + { "path": "../../typert/protocol" }, + { "path": "../../workspace/workspace" } + ] +} diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json new file mode 100644 index 0000000000..76c584fd01 --- /dev/null +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/invariant.ts", + "src/types.ts", + "src/commands.ts", + "src/feed.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../core/session" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../storage/storage-domain" }, + { "path": "../../typert/protocol" }, + { "path": "../../workspace/workspace" } + ] +} diff --git a/packages/api/workspace-controller/tsconfig.json b/packages/api/workspace-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/workspace-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/workspace-controller/tsdown.config.ts b/packages/api/workspace-controller/tsdown.config.ts new file mode 100644 index 0000000000..7bc3021981 --- /dev/null +++ b/packages/api/workspace-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-workspace-controller', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index ed3d431184..4eb4094c0c 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -96,6 +96,14 @@ - id: plugin-inventory name: '@deepseek-ai/dsh-host-plugin-inventory' + # Session commands, cold reads, and live control over Typert Remote. + - id: session-controller + name: '@deepseek-ai/dsh-api-session-controller' + + # Workspace commands and reconnect-safe projection over Typert Remote. + - id: workspace-controller + name: '@deepseek-ai/dsh-api-workspace-controller' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway @@ -169,9 +177,6 @@ - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' - - id: client-runtime - name: '@deepseek-ai/dsh-client-runtime' - - id: cordis-client-runner name: '@deepseek-ai/dsh-cordis-client-runner' @@ -187,6 +192,9 @@ - id: ui-renderer name: '@deepseek-ai/dsh-client-ui-renderer' + - id: ui-session + name: '@deepseek-ai/dsh-client-ui-session' + - id: ui-sidebar name: '@deepseek-ai/dsh-client-ui-sidebar' @@ -205,6 +213,12 @@ - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' + - id: ui-approval + name: '@deepseek-ai/dsh-client-ui-approval' + + - id: ui-chat + name: '@deepseek-ai/dsh-client-ui-chat' + # Official occupants for the generic sidebar and conversation brand slots. - id: ui-brand-official name: '@deepseek-ai/dsh-client-ui-brand-official' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 530192b0e4..2f26a9daed 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -52,11 +52,12 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", + "@deepseek-ai/dsh-client-ui-approval": "workspace:^", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", @@ -70,6 +71,7 @@ "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -105,6 +107,8 @@ "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 9baa35ebd0..9f6f894abb 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -43,7 +43,7 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md): -1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable. +1. **Data object layer** (React-free): `client/connection` owns transport generations, `api/session-controller/client` owns `ClientSessions` → `SessionManager` → `Session`, `api/workspace-controller/client` owns Workspace state, and `client/store` owns the snapshot-store engine (`defineStore`, `createSnapshotStore`, `shallowEqual`). Store products are bare observable sources with no hook members. 2. **Render machinery** (`ui-renderer`, dynamic plugin): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; production business code carries no ui-renderer value dependency. 3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares. @@ -51,7 +51,7 @@ Non-negotiables across the layers: - **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer. - **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest

`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). -- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`. +- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`. - **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule). ## Dependency declaration @@ -72,9 +72,9 @@ Client business code may statically read `process.env.DSH_CLIENT_*`; every refer ## Shared modules and the module graph -A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI libraries; `PRELOADED_CLIENT_EXTERNALS` names dynamic rows, currently runtime, whose ordinary `lib/client.js` factory arrives before shell boot. +A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static Client libraries; `PRELOADED_CLIENT_EXTERNALS` is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists. -1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, runtime, `ui-primitives`, or `ui-slots` in package manifests. +1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, `client/store`, `ui-primitives`, or `ui-slots` in package manifests. 2. **`dsh.client.external` adds a package-specific request.** Use it only for a non-baseline value import whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row. 3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request. 4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol. diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index d80ae58f90..db771381dd 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: b18aad486cd7d3fafe8261fee61fa9e26a7feaa6 -README.zh.md: 58c9aeb5c91f13d9306ba8de16de5af760fd43a2 +README.md: eaf01b282ead3c4638435e3d02d6a803ad1faa15 +README.zh.md: 2ff4b1529f07e0f31b3d06a9577d3c480dd34916 diff --git a/packages/client/README.md b/packages/client/README.md index b18aad486c..eaf01b282e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -10,11 +10,12 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-renderer/`](ui-renderer/README.md) | Binds slot data to React and mounts the assembled application after client boot settles. | | [`modules/`](modules/README.md) | Loads browser-side client modules. | | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery. | -| [`runtime/`](runtime/README.md) | Provides shared client services for sessions, workspaces, and UI composition. | | [`hmr/`](hmr/README.md) | Refreshes client plugins during development. | | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries. | +| [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives. | | [`test-runtime/`](../test-support/client-runtime/README.md) | Provides shared repository test support for client feature packages. | | [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. | +| [`ui-session/`](ui-session/README.md) | Adapts Session Controller state into standard Slot sources and hooks. | | [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. | | [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. | | [`ui-attachment/`](ui-attachment/README.md) | Registers composer and message-image attachment presentation. | @@ -23,6 +24,8 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks. | | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. | | [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. | +| [`ui-chat/`](ui-chat/README.md) | Projects and renders the Chat conversation target. | +| [`ui-approval/`](ui-approval/README.md) | Presents approval requests and returns user decisions. | | [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. | | [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. | | [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 58c9aeb5c9..2ff4b1529f 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -10,11 +10,12 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-renderer/`](ui-renderer/README.zh.md) | 将 slot 数据绑定到 React,并在客户端启动稳定后挂载组装完成的应用。 | | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块。 | | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信和事件传递。 | -| [`runtime/`](runtime/README.zh.md) | 为会话、工作区和 UI 组合提供共享客户端服务。 | | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件。 | | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典。 | +| [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与 snapshot-store 基础设施。 | | [`test-runtime/`](../test-support/client-runtime/README.zh.md) | 为客户端功能包提供共享的仓库测试支持。 | | [`ui-slots/`](ui-slots/README.zh.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 | +| [`ui-session/`](ui-session/README.zh.md) | 把 Session Controller 状态适配为标准 Slot source 与 hook。 | | [`ui-theme/`](ui-theme/README.zh.md) | 应用所选颜色主题。 | | [`ui-primitives/`](ui-primitives/README.zh.md) | 提供共享 React 控件、图标和内容渲染器。 | | [`ui-attachment/`](ui-attachment/README.zh.md) | 注册输入框与消息图片的附件呈现。 | @@ -23,6 +24,8 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-brand-official/`](ui-brand-official/README.zh.md) | 使用官方名称和标记填充通用浏览器品牌 slot。 | | [`ui-workspace/`](ui-workspace/README.zh.md) | 提供工作区选择与创建界面。 | | [`ui-conversation/`](ui-conversation/README.zh.md) | 展示当前对话及其输入界面。 | +| [`ui-chat/`](ui-chat/README.zh.md) | 投影并渲染 Chat conversation target。 | +| [`ui-approval/`](ui-approval/README.zh.md) | 展示审批请求并返回用户决定。 | | [`ui-tool/`](ui-tool/README.zh.md) | 编排工具调用树和按工具键控的视图。 | | [`ui-workflow-run/`](ui-workflow-run/README.zh.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 | | [`ui-goal/`](ui-goal/README.zh.md) | 展示和管理当前目标。 | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 9d430a14a2..8f1d179212 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 71ef204a589bb67c15ccab58d3cac5a13782ce27 -README.zh.md: 6d33ac3c13cdfceeba6e7472b618084267d09bbc +README.md: d2614515744ee69ca11443a7bc440a589d3f26b3 +README.zh.md: 13df74ddf7bb21455bb5528119bd7c3d5d149b87 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 71ef204a58..d261451574 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,15 +2,21 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The exported `ClientTransportHooks` names the page global `__DSH_TRANSPORT__` that replaces the browser carrier wholesale: the served web app leaves it unset and gets HTTP + WebSocket, while a shell owning a different physical transport (the worker preview's postMessage tunnel) provides `createApiClient` and `fetch` — plus `loadBundle` when it also owns bundle bytes — instead of forking the plugin. The Host half owns the single `/api` route and its Fetch bridge; a registered Typert interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md). +Protocol and connection-generation layer. The Client plugin mounts `ctx.connection`, containing the shared API client, current-page loopback state, generation-scoped observable `hostDescription`, a generic RPC carrier, and the registration point for one generation source and the connection loop. A generation publishes `hostDescription` and calls `onConnected` only after its source is ready and `host.describe` succeeds; source completion, failure, withdrawal, or an explicit stop clears that value before `ConnectionController` reconnects with backoff. + +The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, and trust checks. Typert Gateway claims its Remote endpoints first, and unclaimed requests fall through to API Proxy. Loopback hostname classification remains package-internal: the Host fence and WebSocket upgrade use it directly, while other Client plugins consume `ctx.connection.isLoopback`. + +The Node half keeps privileged methods (`host.pickDirectory`, `host.openPath`, the settings and credentials configuration planes, `llm.discoverModels`, and `agentPreset.read`/`copy`/`openDocument`/`remove`) loopback-only by passing an empty trust list to the fence. `agentPreset.list` and `agentPreset.select` are excluded: the roster carries only ids and trust levels, while `session.create` already selects a preset. Declared `trustedHosts` authorities can reach other methods; privileged operations remain loopback-only until a real authentication layer exists. ## /api browser-trust fence The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, deployment-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. Non-loopback compositions must trust their serving authorities explicitly: the Web runtime derives LAN IP literals from an all-interfaces server config, while `trustedHosts` in cordis.yml and the CLI's `--trusted-host` flag declare named authorities. `dsh web --host 0.0.0.0` is intentionally unsupported until remote access has an authentication layer. The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). -## `/api` WebSocket downlinks +## Connection generation -`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier. +API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready' }` item before events. `ConnectionController` waits for that item and `host.describe` in parallel; `onConnected` cannot start baseline reads until both succeed, so baseline acquisition cannot race ahead of incremental observation. + +An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws `hostDescription`, publishes `reconnecting`, and rebuilds the `$events` plus `host.describe` handshake after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. ## Model Experience @@ -22,5 +28,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path. - **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6d33ac3c13..13df74ddf7 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,15 +2,21 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。导出的 `ClientTransportHooks` 命名了整体替换浏览器载体的页面全局量 `__DSH_TRANSPORT__`:served web app 不设置它、走 HTTP + WebSocket;拥有另一种物理传输的壳(worker 预览的 postMessage 隧道)则在此提供 `createApiClient` 与 `fetch`——当它同时持有 bundle 字节时再加 `loadBundle`——而不必 fork 本插件。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 Typert interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent(智能体) preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md)。 +协议与连接世代层:Client 插件挂载 `ctx.connection`,包含共享 API 客户端、当前页面的 loopback 状态、按 generation 生效的可观察 `hostDescription`、通用 RPC carrier,以及单一 generation source 与连接循环的注册面。每个 generation 只在 source 已就绪且 `host.describe` 成功后发布 `hostDescription` 并调用 `onConnected`;source 结束、失败、被撤回或显式 stop 都会清空该值,再由 `ConnectionController` 退避重连。 + +浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge 和信任校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定留在包内:Host fence 与 WebSocket upgrade 直接使用它,其他 Client 插件消费 `ctx.connection.isLoopback`。 + +node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,整个 settings 与 credentials 配置面,`llm.discoverModels`,以及 `agentPreset.read`/`copy`/`openDocument`/`remove`)以空信任表过 fence,从而钉在回环本机。`agentPreset.list` 与 `agentPreset.select` 不在其中:名单只携带 id 与信任级别,而 `session.create` 已能选择 preset。已声明的 `trustedHosts` 授权可达其余方法;在真正的认证层出现前,特权面始终只限回环。 ## /api 浏览器信任栅栏 node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、部署推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,如附带 `Origin`,则它必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何事件流前拒绝握手。非回环组合必须显式信任其服务权威:Web 运行时从全接口服务器配置推导 LAN IP 字面量,cordis.yml 中的 `trustedHosts` 与 CLI(命令行界面)的 `--trusted-host` flag 则声明具名权威。`dsh web --host 0.0.0.0` 在远程访问具备认证层之前有意不受支持。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)。 -## `/api` WebSocket 下行 +## Connection generation -`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` 文本消息;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket 均已打开且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE(Server-Sent Events)回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。 +API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready' }` 项,再发送事件。`ConnectionController` 并行等待该 ready 与 `host.describe`;只有两者都成功才允许 `onConnected` 启动 baseline 读取,因此 baseline 不会跑在增量 listener 前面。 + +`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 `hostDescription`、发布 `reconnecting`,并在退避后重建 `$events` 与 `host.describe` 握手。Gateway mux 自己负责重建底层 WebSocket;Connection 世代负责重建 logical stream 与 baseline 起点。 ## 模型体验 @@ -22,5 +28,4 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r ## 已知限制与暂缓事项 -- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。 - **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index eab7707e3b..478468f637 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-connection", - "description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api", + "description": "Wire consumer layer: HTTP client, generation lifecycle, and fixture API", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -38,8 +38,7 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "ws": "^8.21.0" + "@deepseek-ai/schemastery": "workspace:^" }, "files": [ "lib/index.js", @@ -62,7 +61,6 @@ "devDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", diff --git a/packages/client/connection/src/api-path.ts b/packages/client/connection/src/api-path.ts index f34aa231d4..ee54f61b03 100644 --- a/packages/client/connection/src/api-path.ts +++ b/packages/client/connection/src/api-path.ts @@ -1,14 +1,7 @@ /** * The /api URL prefix — single source for both halves of the web transport. - * The node half registers this prefix on the web server; both halves share the - * event paths below for the browser WebSocket downlinks. + * The node half registers this prefix on the web server. */ /** Route prefix owning every api request (`/api` and `/api/`). */ export const API_PATH = '/api' - -/** Browser mux-frame WebSocket pathname. */ -export const MUX_EVENTS_PATH = `${API_PATH}/events.mux` - -/** Browser host-frame WebSocket pathname. */ -export const HOST_EVENTS_PATH = `${API_PATH}/events.host` diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index ea8914ccc6..1065dfe876 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -13,15 +13,10 @@ * belongs to the webserver config, and this fence is not an auth layer. */ -import type { IncomingHttpHeaders } from 'node:http' import { isLoopbackHostname } from './loopback-hostname.ts' +import type { ConnectionTrustRequest } from './rpc.ts' -/** The request facts the fence reads from either HTTP representation. */ -interface ApiTrustRequest { - headers: IncomingHttpHeaders | Headers -} - -function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined { +function header(headers: ConnectionTrustRequest['headers'], name: string): string | undefined { if (headers instanceof Headers) return headers.get(name) ?? undefined const value = headers[name] return typeof value === 'string' ? value : undefined @@ -93,7 +88,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. */ -export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { +export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[]): boolean { // Host fence (DNS-rebinding defense), applied to every request: the browser // fills Host from the URL it believes it is talking to, so a rebound page // carries the attacker's domain here even though the socket lands on this diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 1b7627b293..e712ca5c3c 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -1,35 +1,32 @@ -// Central contract re-export point: every contract import inside -// web-runtime goes through this single file. +// Central contract re-export point: every legacy API contract import inside +// the Connection package goes through this browser-safe file. // Types and runtime protocol helpers/bounds come from the apiproxy api/ layer // (zero Node deps, browser-safe); AbstractApiClient is the client boundary. // NEVER import the package root: it drags bootHost/cordis into the browser bundle. // The ./api and ./client subpath exports are the browser-safe channels. export type { - ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApiProxy, HostApi, DirectoryEntry, DirectoryListing, - ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, + ResponseValue, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, + ModelReasoningEffort, ModelSelection, GoalsApi, GoalRef, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, - JobView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, - ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, + ClientRequest, ServerResponse, RpcMessage, } from '@deepseek-ai/dsh-host-apiproxy/api' // transportError lives in the apiproxy api layer (beside RpcResult, its // subject); re-exported here so connection consumers keep one contract // entry point. export { RpcId, - SESSION_SEARCH_RESULT_LIMIT, transportError, } from '@deepseek-ai/dsh-host-apiproxy/api' export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 8b41053424..cad2de394d 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,4 +1,4 @@ -import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' +import type { HostDescription, IApiClient } from './api.ts' /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the * future `ctx.connection` plugin's Config). All fields optional; defaults below. */ @@ -9,18 +9,15 @@ export interface ConnectionConfig { backoffFactor?: number /** Upper bound for the backoff cap in ms. */ backoffMaxMs?: number - /** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake - * waits for mux+host stream establishment plus describe; a carrier that never - * fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the - * generation proceeds as connected and the live-gap repair path covers stragglers. */ - streamOpenTimeoutMs?: number + /** Maximum wait for the registered generation source's ready signal. */ + generationReadyTimeoutMs?: number } const CONNECTION_DEFAULTS: Required = { backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000, - streamOpenTimeoutMs: 3_000, + generationReadyTimeoutMs: 3_000, } function sleep(ms: number, signal: AbortSignal): Promise { @@ -39,12 +36,9 @@ function sleep(ms: number, signal: AbortSignal): Promise { * 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */ export type ConnectionState = 'connected' | 'reconnecting' -/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to - * SessionManager. */ +/** Connection-generation callbacks owned by API Gateway. */ export interface ConnectionSinks { - onMuxEnvelope?: (envelope: RpcRequest) => void - onHostEnvelope?: (envelope: RpcRequest) => void - /** After each connection generation is established (both streams open + describe succeeded), first connect included. */ + /** After the generation source is ready and host.describe succeeds, first connect included. */ onConnected?: (description: HostDescription) => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ @@ -52,11 +46,22 @@ export interface ConnectionSinks { } /** - * Opens both streams and keeps iterating (pull mode: nothing reads the socket and the tap - * never fires unless someone for-awaits), reconnecting with exponential backoff on loss. + * One long-lived source defining a Connection generation. The source must + * attach its incremental listeners before calling `ready`, then remain pending + * until the generation is lost or `signal` aborts. + * @param signal - cancellation for the current generation. + * @param ready - one-shot report that incremental delivery is attached. + * @returns a promise settling only when this generation ends or fails. + */ +export type ConnectionGenerationSource = ( + signal: AbortSignal, + ready: () => void, +) => Promise + +/** + * Opens the registered generation source, reconnecting with exponential backoff on loss. * State (generation/attempt) is instance-private, never in the store. - * The pump body feeds each frame to a sink (sink exceptions must - * not kill the pump — a broken business layer must not drag down the connection layer). + * Sink exceptions do not kill the generation loop. */ export class ConnectionController { private generation = 0 @@ -68,6 +73,7 @@ export class ConnectionController { constructor( private readonly api: IApiClient, + private readonly source: ConnectionGenerationSource, private readonly sinks: ConnectionSinks = {}, config: ConnectionConfig = {}, ) { @@ -81,7 +87,7 @@ export class ConnectionController { void this.loop() } - /** Stop the loop and abort the current generation's streams. */ + /** Stop the loop and abort the current generation source. */ stop(): void { this.running = false this.current?.abort() @@ -110,37 +116,58 @@ export class ConnectionController { const ac = new AbortController() this.current = ac - /* v8 ignore next -- initializer placeholder: the Promise executor - * below runs synchronously and replaces it before anyone can call it. */ - let muxOpened = (): void => {} - /* v8 ignore next -- same placeholder pattern as muxOpened. */ - let hostOpened = (): void => {} - const streamsOpen = Promise.all([ - new Promise((resolve) => { muxOpened = resolve }), - new Promise((resolve) => { hostOpened = resolve }), - ]) + let sourceReady = false + let resolveReady!: () => void + let rejectReady!: (error: Error) => void + let rejectSourceLost!: (error: Error) => void + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + const sourceLost = new Promise((_resolve, reject) => { + rejectSourceLost = reject + }) + const reportReady = (): void => { + sourceReady = true + resolveReady() + } const failed = new Promise((resolve) => { const settle = (): void => { if (gen === this.generation && !ac.signal.aborted) ac.abort() resolve() } - void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle) - void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle) + void Promise.resolve() + .then(() => this.source(ac.signal, reportReady)) + .then( + () => { + const error = new Error('connection generation ended') + if (!sourceReady) rejectReady(error) + rejectSourceLost(error) + settle() + }, + (error: unknown) => { + const failure = error instanceof Error + ? error + : new Error('connection generation failed', { cause: error }) + if (!sourceReady) rejectReady(failure) + rejectSourceLost(failure) + settle() + }, + ) }) try { - // Strict readiness handshake: describe proves unary reachability, onOpen - // proves each physical stream is established before any frame — - // only then may onConnected fire, so the resync it triggers cannot outrun the - // subscribed baseline. The timeout guards against a carrier that never fires onOpen - // (see ConnectionConfig.streamOpenTimeoutMs). - const timeout = new AbortController() - const [description] = await Promise.all([ - this.api.host.describe({}), - Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]), + // The source reports ready only after its incremental listeners exist; + // describe may complete in parallel, but consumers see neither result + // until both sides of the baseline-plus-increment handshake are ready. + const [description] = await Promise.race([ + Promise.all([ + this.api.host.describe({}, ac.signal), + waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), + ]), + sourceLost, ]) - timeout.abort() const descriptionResult = description.result if (!descriptionResult.ok) { throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) @@ -162,7 +189,7 @@ export class ConnectionController { if (!this.isRunning()) return this.emitState('reconnecting') this.attempt += 1 - console.warn(`[web-runtime] connection lost, retry #${this.attempt}`) + console.warn(`[connection] connection lost, retry #${this.attempt}`) const idle = new AbortController() await sleep(this.backoffDelay(this.attempt), idle.signal) } @@ -175,28 +202,40 @@ export class ConnectionController { this.callSink(() => this.sinks.onStateChange?.(state)) } - private async pumpStream( - stream: AsyncIterable>, - sink: ((envelope: RpcRequest) => void) | undefined, - onEnd: () => void, - ): Promise { - try { - for await (const envelope of stream) { - if (envelope.payload.type === 'stream/error') break - if (sink !== undefined) this.callSink(() => { sink(envelope) }) - } - } catch { - // Stream loss: converge on onEnd, which triggers the shared reconnect. - } - onEnd() - } - /** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */ private callSink(fn: () => void): void { try { fn() } catch (error) { - console.error('[web-runtime] connection sink threw:', error) + console.error('[connection] connection sink threw:', error) } } } + +/** Await source readiness without letting a stalled carrier wedge startup forever. */ +function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let settled = false + const timeout = setTimeout(() => { + finish(new Error(`connection generation was not ready within ${String(timeoutMs)}ms`)) + }, timeoutMs) + const aborted = (): void => { + finish(new Error('connection generation aborted', { cause: signal.reason })) + } + const finish = (error?: Error): void => { + if (settled) return + settled = true + clearTimeout(timeout) + signal.removeEventListener('abort', aborted) + if (error === undefined) resolve() + else reject(error) + } + signal.addEventListener('abort', aborted, { once: true }) + void ready.then( + () => { finish() }, + (error: unknown) => { + finish(error as Error) + }, + ) + }) +} diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 603cd2325c..5dedcee80b 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1,5 +1,4 @@ -// Standalone browser fixture. It models server-owned frame rpcIds and echoes -// unary request rpcIds through the production carrier types. +// Standalone browser fixture for UI development without a server. import { createAssistantMessage, @@ -7,7 +6,7 @@ import { createUserMessage, isTokenDelta, } from '@deepseek-ai/dsh-llm/message' -import { CallId } from '@deepseek-ai/dsh-llm/brand' +import { CallId, type MessageId } from '@deepseek-ai/dsh-llm/brand' import type { AssistantMessage, ContentBlock, @@ -28,14 +27,263 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { - ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, - ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, - ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, + ApiProxy, ClientRequest, + ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse, + ToolCallView, ToolResultView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' -import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts' +import { AbstractApiClient, RpcId } from './api.ts' import { randomUuid } from './random-uuid.ts' -import type { ClientConnectionRpc } from '../rpc.ts' +import type { + ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, +} from '../rpc.ts' + +const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20 + +interface FixtureSessionSummary { + readonly sessionId: SessionId + updatedAt: number + running: boolean + blank: boolean + readonly parentSessionId?: SessionId + readonly origin?: 'subagent' + readonly cwd?: string + readonly agentPreset?: string + readonly projections?: FixtureProjectionsBlock +} + +interface FixtureProjectionsBlock { + readonly asOfSeq: number + readonly values: Readonly> +} + +type FixtureToolView = + | { readonly for: 'call'; readonly view: ToolCallView } + | { readonly for: 'result'; readonly view: ToolResultView } + +interface FixtureHistoryEntry { + readonly event: SessionEvent + readonly view?: FixtureToolView +} + +type FixtureSessionAddress = + | { readonly kind: 'session'; readonly sessionId: SessionId } + | { + readonly kind: 'subagent' + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + readonly mode: 'one-shot' | 'continuable' + } + +interface FixtureFollowRequest { + readonly address: FixtureSessionAddress + readonly afterSeq?: number +} + +interface FixturePageRequest { + readonly address: FixtureSessionAddress + readonly throughSeq: number + readonly beforeSeq?: number + readonly maxMessages?: number +} + +type FixtureFollowFrame = + | { readonly type: 'opened'; readonly cursor: number } + | ({ readonly type: 'event' } & FixtureHistoryEntry) + +type FixtureFollowEventFrame = Extract + +interface FixtureRemoteEventNotificationFrame { + readonly type: 'emit' + readonly event: string + readonly args: readonly unknown[] +} + +interface FixtureRemoteEventInvocationFrame { + readonly type: 'waterfall' + readonly event: string + readonly eventId: string + readonly agentId: SessionId + readonly request: Readonly> +} + +interface FixtureRemoteEventCancellationFrame { + readonly type: 'cancel' + readonly eventId: string +} + +type FixtureRemoteEventFrame = + | FixtureRemoteEventNotificationFrame + | FixtureRemoteEventInvocationFrame + | FixtureRemoteEventCancellationFrame + +interface FixtureRemoteEventResult { + readonly clientId: string + readonly eventId: string + readonly outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { + readonly kind: 'rejected' + readonly error: { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown + } + } +} + +interface FixtureRemoteEventReadyFrame { + readonly type: 'ready' + readonly clientId: string +} + +interface FixtureProjectionFrame { + readonly type: 'projection' + readonly sessionId: SessionId + readonly key: string + readonly value: unknown + readonly seq: number +} + +interface FixtureQuestionItem { + readonly id: string + readonly header?: string + readonly question: string + readonly detail?: string + readonly multiSelect?: boolean + readonly options?: readonly { readonly label: string; readonly description?: string }[] +} + +type FixtureControlFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly queues: Readonly> + readonly jobs: Readonly> + readonly approvals: readonly never[] + readonly questions: readonly never[] + readonly projections: Readonly> + } + } + | FixtureProjectionFrame + +type FixturePromptPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageAttachmentRef['mediaType'] + readonly data: string + readonly name?: string + } + +interface FixtureSessionApi { + list(request: { readonly cursor?: string }): Promise> + search( + request: { readonly query: string }, + signal: AbortSignal, + ): Promise> + create(request: { + readonly workspaceId?: WorkspaceId + readonly cwd?: string + readonly sessionId?: SessionId + readonly agentPreset?: string + }): Promise> + rename(request: { readonly sessionId: SessionId; readonly title: string }): Promise> + fork(request: { readonly sessionId: SessionId; readonly atSeq?: number }): Promise> + history(request: { + readonly sessionId: SessionId + readonly throughSeq?: number + readonly beforeSeq?: number + readonly maxMessages?: number + }): Promise> + models(request: { readonly sessionId: SessionId }): Promise> + selectModel(request: { + readonly sessionId: SessionId + readonly provider: string + readonly model: string + readonly reasoningEffort?: string + }): Promise> + prompt(request: { + readonly requestId: string + readonly sessionId: SessionId + readonly mode: 'queue' | 'steer' + readonly content: readonly FixturePromptPart[] + readonly clientTimeZone?: string + }): Promise> + attachment(request: { + readonly sessionId: SessionId + readonly attachmentId: AttachmentIdType + }): Promise> + updateQueue(request: { + readonly sessionId: SessionId + readonly itemId: MessageId + readonly action: unknown + }): Promise> + cancel(request: { readonly sessionId: SessionId }): Promise> +} + +type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } + +interface WorkspaceView { + readonly workspaceId: WorkspaceId + readonly path: string + readonly title: string + readonly sessionIds: readonly SessionId[] + readonly createdAt: string + readonly updatedAt: string +} + +interface WorkspaceCreateRequest { readonly path: string } +interface WorkspaceCreateValue { readonly workspace: WorkspaceView; readonly created: boolean } +interface WorkspaceRenameRequest { readonly workspaceId: WorkspaceId; readonly title: string } +interface WorkspaceValue { readonly workspace: WorkspaceView } +interface WorkspaceDeleteRequest { readonly workspaceId: WorkspaceId } +interface WorkspaceDeleteValue { readonly deleted: true } +interface WorkspaceInsertBeforeRequest { + readonly workspaceId: WorkspaceId + readonly beforeWorkspaceId?: WorkspaceId +} +interface WorkspaceOrderValue { readonly workspaceIds: readonly WorkspaceId[] } +interface WorkspaceInsertSessionBeforeRequest { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId +} +interface WorkspaceArchiveSessionRequest { readonly sessionId: SessionId } +interface WorkspaceArchiveValue { readonly archivedSessionIds: readonly SessionId[] } + +type WorkspaceFollowFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly items: readonly WorkspaceView[] + readonly archivedSessionIds: readonly SessionId[] + } + } + | { readonly type: 'upsert'; readonly workspace: WorkspaceView } + | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } + | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + +interface FixtureWorkspaceApi { + create(request: WorkspaceCreateRequest): Promise> + rename(request: WorkspaceRenameRequest): Promise> + delete(request: WorkspaceDeleteRequest): Promise> + insertBefore(request: WorkspaceInsertBeforeRequest): Promise> + insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise> + archiveSession(request: WorkspaceArchiveSessionRequest): Promise> +} + +interface FixtureWorkspace { + workspaceId: WorkspaceId + path: string + title: string + sessionIds: SessionId[] + createdAt: string + updatedAt: string +} /** The fake carrier mints like a real one (business code never mints). */ function rpcRequest

(payload: P): RpcRequest

{ @@ -693,7 +941,7 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR } /** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */ -function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined { +function viewFor(event: SessionEvent, log: readonly SessionEvent[]): FixtureToolView | undefined { if (event.type === 'tool/call') { const view = presentCall(event.data.name, event.data.arguments) return view === undefined ? undefined : { for: 'call', view } @@ -1065,20 +1313,24 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record[] { +/** Host parallel: emit one Session control projection frame per key advanced by the event. */ +function projectionFramesOf( + id: SessionId, + log: readonly SessionEvent[], + event: SessionEvent, +): FixtureProjectionFrame[] { const type = (event as { type: string }).type - const frames: Extract[] = [] + const frames: FixtureProjectionFrame[] = [] // One usage sample advances both token-meter units. if (usageSampleOf(event) !== undefined) { frames.push( - { type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq }, - { type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq }, + { type: 'projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq }, + { type: 'projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq }, ) } if (type === 'request/context') { frames.push({ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), @@ -1090,7 +1342,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: || type === 'assistant/message' || type === 'tool/result') { frames.push({ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'contextBreakdown', value: contextBreakdownOf(log), @@ -1101,7 +1353,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: // (wall times) and on step close (counts). if (type === 'assistant/message' || type === 'tool/result' || type === 'step/end') { frames.push({ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'sessionStats', value: sessionStatsOf(log), @@ -1113,16 +1365,16 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: const values = projectionValuesOf(log) /* v8 ignore next -- the advancing title event is in the log, so the key is present. */ if (!Object.hasOwn(values, 'title')) return [] - return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }] + return [{ type: 'projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }] } // The goal domain's own durable change advances its projection. if (type === 'goal/change') { - return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] + return [{ type: 'projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] } // Standing-plan fold: writes replace the list; turn/start clears it (null). if (type === 'todo/write' || type === 'turn/start') { return [{ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'todos', value: backscanTodos(log) ?? null, @@ -1132,7 +1384,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: // Knob fold: any of the three whole-value knob events advances the select. if (type === 'permission/preset' || type === 'sandbox/mode' || type === 'approval/policy') { return [{ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'permissions', value: permissionSelectOf(log), @@ -1145,7 +1397,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: if (type === 'plan/mode' || (type === 'command/run' && commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) { return [{ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'plan', value: planViewOf(log), @@ -1165,7 +1417,7 @@ function pageOf( log: readonly SessionEvent[], beforeSeq: number | undefined, maxMessages: number, -): { events: HistoryEntry[]; hasMore: boolean } { +): { events: FixtureHistoryEntry[]; hasMore: boolean } { const end = beforeSeq === undefined ? log.length : Math.max(0, Math.min(beforeSeq, log.length)) let start = 0 let messages = 0 @@ -1179,7 +1431,7 @@ function pageOf( break } } - const events = log.slice(start, end).map((event): HistoryEntry => { + const events = log.slice(start, end).map((event): FixtureHistoryEntry => { const view = viewFor(event, log) return view === undefined ? { event } : { event, view } }) @@ -1395,8 +1647,8 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null { return null } -interface StreamConn { - push(envelope: RpcRequest): void +interface StreamConn { + push(value: Value): void } interface ReasoningChunkStormState { @@ -1427,13 +1679,13 @@ export interface FixtureOptions { * outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and * piles up for the stream's lifetime). breakNow force-ends the stream without the * client's signal (timing hook: simulated connection loss). */ -class FxInbox implements StreamConn { - private readonly inbox: RpcRequest[] = [] +class FxInbox implements StreamConn { + private readonly inbox: Value[] = [] private wake: (() => void) | null = null private broken = false - push(envelope: RpcRequest): void { - this.inbox.push(envelope) + push(value: Value): void { + this.inbox.push(value) this.wake?.() } @@ -1447,12 +1699,12 @@ class FxInbox implements StreamConn { return !signal.aborted && !this.broken } - async *drain(signal: AbortSignal): AsyncGenerator> { + async *drain(signal: AbortSignal): AsyncGenerator { const onAbort = (): void => this.wake?.() signal.addEventListener('abort', onAbort) try { while (this.isLive(signal)) { - while (this.inbox.length > 0) yield this.inbox.shift() as RpcRequest + while (this.inbox.length > 0) yield this.inbox.shift() as Value if (!this.isLive(signal)) break await new Promise((resolve) => { this.wake = resolve @@ -1495,7 +1747,7 @@ export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld { /** Build the fixture's legacy API and Remote RPC faces over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. - const sessions: SessionSummary[] = options.empty ? [] : [ + const sessions: FixtureSessionSummary[] = options.empty ? [] : [ { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, @@ -1528,14 +1780,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { let fixtureDefaultPreset = 'standard' const nextTurn = new Map([[sid('fx-alpha'), 75]]) let nextSession = 1 - let nextRpc = 1 let attachedSessions = options.empty ? 0 : 1 // Workspace entities mirroring the host registry: the fixture sessions all // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() const FIXTURE_HOME = '/home/fixture' - const workspaces: WorkspaceView[] = options.empty ? [] : [{ + const workspaces: FixtureWorkspace[] = options.empty ? [] : [{ workspaceId: wid('fx-ws-fixture'), path: '/tmp/fixture', title: 'fixture', @@ -1554,6 +1805,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // Registry-global archive set mirroring the host: archived sessions keep // their workspace accounting slot and only grouping surfaces hide them. const archivedSessionIds: SessionId[] = [] + const workspaceSnapshot = (workspace: FixtureWorkspace): WorkspaceView => ({ + ...workspace, + sessionIds: [...workspace.sessionIds], + }) + const workspaceBaseline = (): Extract => ({ + type: 'baseline', + value: { + items: workspaces.map(workspaceSnapshot), + archivedSessionIds: [...archivedSessionIds], + }, + }) // In-memory browse tree behind the fixture's `browse` picker capability — // deterministic content mirroring the design mock so assembled Web tests @@ -1584,15 +1846,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } return crumbs } - const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) - /** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */ - const pendingApprovalRpcId = mint() - const pendingApprovalId = 'fx-approval-1' as Extract['approvalId'] - /** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */ - let approvalPending = true - const pendingQuestionRpcId = mint() - let questionPending = true - const fixtureQuestions: Extract['questions'] = [ + /** Resident waterfalls retain their event ids across Remote Event generations. */ + const pendingApprovalEventId = 'fx-interaction-approval' + let approvalPending = !options.empty + const pendingQuestionEventId = 'fx-interaction-question' + let questionPending = !options.empty + const fixtureQuestions: readonly FixtureQuestionItem[] = [ { id: 'harness-profile', header: '偏好', @@ -1626,13 +1885,24 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, ] - const muxConns = new Set>() - const hostConns = new Set>() - const emitMux = (frame: MuxFrame): void => { - for (const conn of muxConns) conn.push({ rpcId: mint(), payload: frame }) + const controlConns = new Set>() + const followConns = new Map>>() + const workspaceConns = new Set>() + const remoteEventConns = new Map>() + const emitControl = (frame: FixtureControlFrame): void => { + for (const conn of controlConns) conn.push(frame) } - const emitHost = (frame: HostFrame): void => { - for (const conn of hostConns) conn.push({ rpcId: mint(), payload: frame }) + const emitWorkspace = (frame: Exclude): void => { + for (const conn of workspaceConns) conn.push(frame) + } + const emitRemote = (event: string, args: readonly unknown[]): void => { + for (const conn of remoteEventConns.values()) conn.push({ type: 'emit', event, args }) + } + const emitRemoteFrame = (frame: FixtureRemoteEventFrame): void => { + for (const conn of remoteEventConns.values()) conn.push(frame) + } + const emitFollow = (sessionId: SessionId, entry: FixtureHistoryEntry): void => { + for (const conn of followConns.get(sessionId) ?? []) conn.push({ type: 'event', ...entry }) } /** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */ @@ -1643,7 +1913,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return Promise.resolve({ rpcId: request.rpcId, result: { ok: false, error } }) } - const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id) + function sessionOk(value: T): Promise> { + return Promise.resolve({ ok: true, value }) + } + + function sessionErr(error: ConnectionRpcFailure): Promise> { + return Promise.resolve({ ok: false, error }) + } + + const summaryOf = (id: SessionId): FixtureSessionSummary | undefined => sessions.find(s => s.sessionId === id) /** Shared session guard for sessionId-addressed catalog routes: the error * response when the session is unknown, undefined when it exists. */ const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise> | undefined => { @@ -1654,11 +1932,21 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { details: { sessionId: request.payload.sessionId }, }) } + const requireRemoteSession = ( + request: { readonly sessionId: SessionId }, + ): Promise> | undefined => { + if (summaryOf(request.sessionId) !== undefined) return undefined + return sessionErr({ + code: 'session-not-found', + message: `no session ${request.sessionId}`, + details: { sessionId: request.sessionId }, + }) + } const setRunning = (id: SessionId, running: boolean): void => { const summary = summaryOf(id) if (summary === undefined || summary.running === running) return summary.running = running - emitHost({ type: 'host/session-status', sessionId: id, running }) + emitRemote('api-session/status', [id, running]) } const logOf = (id: SessionId): SessionEvent[] => { let log = logs.get(id) @@ -1674,14 +1962,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { log.push(event) // Emission-time view derivation (mirrors the host's live path). const view = viewFor(event, log) - /* v8 ignore next 3 -- the view-present arm needs a live tool/call emission, + /* v8 ignore next 2 -- the view-present arm needs a live tool/call emission, but the fixture replay produces text-only turns; view vocabulary is exercised through the history samples (turns 60-62). */ - emitMux(view === undefined - ? { type: 'session/event', sessionId: id, event } - : { type: 'session/event', sessionId: id, event, view }) + emitFollow(id, view === undefined ? { event } : { event, view }) // Host eager-drive parallel: a unit-advancing event pushes its finished value. - for (const frame of projectionFramesOf(id, log, event)) emitMux(frame) + for (const frame of projectionFramesOf(id, log, event)) emitControl(frame) + if (event.type === 'user/message' && event.data.source.kind === 'user') { + const summary = summaryOf(id) + if (summary !== undefined) summary.updatedAt = event.time + emitRemote('api-session/activity', [id, event.time]) + } } /** Append one durable goal/change (host GoalService parallel). */ @@ -2012,7 +2303,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { failNextHistory(): void { failNextHistory = true }, - /** Log append + mux emit (the normal live path). */ + /** Log append plus follow-stream delivery (the normal live path). */ appendUser(id: string, msg: string): void { append(sid(id), { type: 'user/message', surfaceOp: 'append', data: userMessage(text(msg)) }) }, @@ -2180,7 +2471,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } }) setRunning(sessionId, false) }, - /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */ + /** Log append without follow delivery: a frame lost in transit that page repair must recover. */ appendSilent(id: string, msg: string): void { const log = logOf(sid(id)) log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent) @@ -2231,353 +2522,669 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { replays.set(id, { timer: setTimeout(tick, 80), finish }) } - const api: ApiProxy = { - sessions: { - list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), - search: (request, signal) => { - if (signal.aborted) { - return err(request, { - code: 'cancelled', - message: 'fixture session search was aborted', - details: {}, - }) - } - const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value) - const matches = sessions.flatMap((summary) => { - const log = logs.get(summary.sessionId) ?? [] - const current = new Set(foldSurface(log).nodes) - const best = log.flatMap((event): FixtureSearchCandidate[] => { - if (!current.has(event.seq)) return [] - const eventText = searchEventText(event) - const document = searchTokenSpans(eventText) - const match = phraseMatch(document.tokens, query) - if (match.count === 0) return [] - return [{ - sessionId: summary.sessionId, - seq: event.seq, - time: event.time, - text: document.text, - matchCount: match.count, - matchStart: match.start, - matchEnd: match.end, - documentLength: Array.from(eventText).length, - }] - }).sort(compareSearchCandidates)[0] - return best === undefined ? [] : [best] - }).sort(compareSearchCandidates) - return ok(request, { - items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({ - sessionId: match.sessionId, - snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), - })), - hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT, + const sessionApi: FixtureSessionApi = { + list: _request => sessionOk({ items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), + search: (request, signal) => { + if (signal.aborted) { + return sessionErr({ + code: 'cancelled', + message: 'fixture session search was aborted', + details: {}, }) - }, - create: async (request) => { - const workspace = request.payload.workspaceId === undefined - ? undefined - : workspaces.find(w => w.workspaceId === request.payload.workspaceId) - if (request.payload.workspaceId !== undefined && workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${request.payload.workspaceId}`, - details: { workspaceId: request.payload.workspaceId }, - }) - } - const cwd = workspace?.path ?? request.payload.cwd ?? '/tmp/fixture' - const requestedId = request.payload.sessionId - const attachWorkspace = (sessionId: SessionId): void => { - /* v8 ignore next -- callers enter only when a target Workspace exists. */ - if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return - workspace.sessionIds = [sessionId, ...workspace.sessionIds] - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - const attachFailure = ( - sessionId: SessionId, - workspaceId: WorkspaceId, - ): Promise> => err(request, { - code: 'workspace-attach-failed' as const, - message: `fixture rejected Workspace attachment for ${sessionId}`, - details: { sessionId, workspaceId }, + } + const query = searchTokenSpans(request.query).tokens.map(token => token.value) + const matches = sessions.flatMap((summary) => { + const log = logs.get(summary.sessionId) ?? [] + const current = new Set(foldSurface(log).nodes) + const best = log.flatMap((event): FixtureSearchCandidate[] => { + if (!current.has(event.seq)) return [] + const eventText = searchEventText(event) + const document = searchTokenSpans(eventText) + const match = phraseMatch(document.tokens, query) + if (match.count === 0) return [] + return [{ + sessionId: summary.sessionId, + seq: event.seq, + time: event.time, + text: document.text, + matchCount: match.count, + matchStart: match.start, + matchEnd: match.end, + documentLength: Array.from(eventText).length, + }] + }).sort(compareSearchCandidates)[0] + return best === undefined ? [] : [best] + }).sort(compareSearchCandidates) + return sessionOk({ + items: matches.slice(0, FIXTURE_SESSION_SEARCH_RESULT_LIMIT).map(match => ({ + sessionId: match.sessionId, + snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), + })), + hasMore: matches.length > FIXTURE_SESSION_SEARCH_RESULT_LIMIT, + }) + }, + create: async (request) => { + const workspace = request.workspaceId === undefined + ? undefined + : workspaces.find(w => w.workspaceId === request.workspaceId) + if (request.workspaceId !== undefined && workspace === undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, }) - if (requestedId !== undefined) { - const existing = summaryOf(requestedId) - if (existing !== undefined) { - if (existing.cwd !== cwd) { - return err(request, { - code: 'session-conflict', - message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, - details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, - }) - } - if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) { - if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId) - attachWorkspace(requestedId) - } - return ok(request, { sessionId: requestedId }) + } + const cwd = workspace?.path ?? request.cwd ?? '/tmp/fixture' + const requestedId = request.sessionId + const attachWorkspace = (sessionId: SessionId): void => { + /* v8 ignore next -- callers enter only when a target Workspace exists. */ + if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return + workspace.sessionIds = [sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + const attachFailure = ( + sessionId: SessionId, + workspaceId: WorkspaceId, + ): Promise> => sessionErr({ + code: 'workspace-attach-failed' as const, + message: `fixture rejected Workspace attachment for ${sessionId}`, + details: { sessionId, workspaceId }, + }) + if (requestedId !== undefined) { + const existing = summaryOf(requestedId) + if (existing !== undefined) { + if (existing.cwd !== cwd) { + return sessionErr({ + code: 'session-conflict', + message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, + details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, + }) } + if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) { + if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId) + attachWorkspace(requestedId) + } + return sessionOk({ sessionId: requestedId }) } - const created: SessionSummary = { - sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, - } - sessions.push(created) - modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - attachedSessions += 1 - const emitSession = (): void => { - // The creation frame precedes later workspace-attachment work. - emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd }) - } - if (workspace !== undefined && options.failWorkspaceAttach) { - emitSession() - return attachFailure(created.sessionId, workspace.workspaceId) - } - if (workspace !== undefined && options.createFrameOrder === 'workspace-first') { - attachWorkspace(created.sessionId) - emitSession() - } else { - emitSession() - if (workspace !== undefined) attachWorkspace(created.sessionId) - } - if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') - return ok(request, { sessionId: created.sessionId }) - }, - rename: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const { sessionId, title } = request.payload - const normalized = title.trim().replace(/\s+/g, ' ') - if (normalized.length === 0) { - return err(request, { - code: 'title-invalid', - message: 'session title must contain visible characters', - details: { sessionId }, - }) - } - // The append emits the session/event and its session/projection frame - // (host parallel); the unary response settles the caller first. - append(sessionId, { - type: 'session/title', - data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, + } + const created: FixtureSessionSummary = { + sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, + } + sessions.push(created) + modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + attachedSessions += 1 + const emitSession = (): void => { + emitRemote('api-session/added', [created]) + } + if (workspace !== undefined && options.failWorkspaceAttach) { + emitSession() + return attachFailure(created.sessionId, workspace.workspaceId) + } + if (workspace !== undefined && options.createFrameOrder === 'workspace-first') { + attachWorkspace(created.sessionId) + emitSession() + } else { + emitSession() + if (workspace !== undefined) attachWorkspace(created.sessionId) + } + if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') + return sessionOk({ sessionId: created.sessionId }) + }, + rename: (request) => { + const missing = requireRemoteSession(request) + if (missing !== undefined) return missing + const { sessionId, title } = request + const normalized = title.trim().replace(/\s+/g, ' ') + if (normalized.length === 0) { + return sessionErr({ + code: 'title-invalid', + message: 'session title must contain visible characters', + details: { sessionId }, }) - const appended = logOf(sessionId).at(-1) as SessionEvent - return ok(request, { title: normalized, seq: appended.seq }) - }, - fork: (request) => { - const { sessionId, atSeq } = request.payload - const source = summaryOf(sessionId) - if (source === undefined) { - return err(request, { - code: 'session-not-found', - message: `no session ${sessionId}`, - details: { sessionId }, - }) - } - const log = logs.get(sessionId) ?? [] - const lastSeq = log.at(-1)?.seq ?? -1 - const anchoredBoundary = atSeq === undefined - ? undefined - : log.find(e => e.type === 'turn/end' && e.seq >= atSeq) - const boundary = anchoredBoundary + } + // The append emits the durable event and its control projection frame; + // the unary response settles the caller first. + append(sessionId, { + type: 'session/title', + data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, + }) + const appended = logOf(sessionId).at(-1) as SessionEvent + return sessionOk({ title: normalized, seq: appended.seq }) + }, + fork: (request) => { + const { sessionId, atSeq } = request + const source = summaryOf(sessionId) + if (source === undefined) { + return sessionErr({ + code: 'session-not-found', + message: `no session ${sessionId}`, + details: { sessionId }, + }) + } + const log = logs.get(sessionId) ?? [] + const lastSeq = log.at(-1)?.seq ?? -1 + const anchoredBoundary = atSeq === undefined + ? undefined + : log.find(e => e.type === 'turn/end' && e.seq >= atSeq) + const boundary = anchoredBoundary ?? (atSeq === undefined || atSeq > lastSeq ? log.findLast(e => e.type === 'turn/end') : undefined) - if (boundary === undefined) { - return err(request, { - code: 'fork-unavailable', - message: atSeq !== undefined && atSeq <= lastSeq - ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` - : `session ${sessionId} has no completed turn`, - details: { sessionId }, - }) - } - let cut = boundary.seq + 1 - while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ - const child: SessionSummary = { - sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, - parentSessionId: sessionId, - ...source.cwd === undefined ? {} : { cwd: source.cwd }, - } - logs.set(child.sessionId, log.slice(0, cut)) - sessions.push(child) - emitHost({ - type: 'host/session-added', sessionId: child.sessionId, blank: false, - parentSessionId: sessionId, - ...source.cwd === undefined ? {} : { cwd: source.cwd }, + if (boundary === undefined) { + return sessionErr({ + code: 'fork-unavailable', + message: atSeq !== undefined && atSeq <= lastSeq + ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` + : `session ${sessionId} has no completed turn`, + details: { sessionId }, }) - const workspace = workspaces.find(w => w.sessionIds.includes(sessionId)) - if (workspace !== undefined) { - workspace.sessionIds = [child.sessionId, ...workspace.sessionIds] - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - return ok(request, { sessionId: child.sessionId }) - }, - history: async (request) => { - const log = logs.get(request.payload.sessionId) ?? [] - // Snapshot at request time, then deliver after the transit delay. - const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50) - // Tail page carries the projections block (host parallel: one consistent - // cut over the registered units; asOfSeq = window tail seq, -1 on an - // empty log — the host's session.seq-1 convention). - const projections = request.payload.beforeSeq === undefined - ? { asOfSeq: log.length - 1, values: projectionValuesOf(log) } - : undefined - const doomed = failNextHistory - failNextHistory = false - const delay = historyDelayMs - if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) - if (doomed) throw new Error('fixture: simulated history transport failure') - return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) - }, - models: request => ok(request, { - current: modelSelections.get(request.payload.sessionId) - ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - // The fixture's routes all serve; a surface exercising the blocked - // posture drives it through its own stub. - routable: true, - groups: fixtureModelGroups(), - failures: [], - }), - selectModel: (request) => { - const selected: ModelSelection = { - provider: request.payload.provider, - model: request.payload.model, - ...request.payload.reasoningEffort === undefined - ? {} - : { reasoningEffort: request.payload.reasoningEffort }, - } - modelSelections.set(request.payload.sessionId, selected) - return ok(request, { selected }) - }, - prompt: (request) => { - const { sessionId: id, mode, content } = request.payload - const summary = summaryOf(id) - if (summary === undefined) { - return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) - } - if (options.rejectPrompt) { - if (content.some(block => block.type === 'image')) { - return err(request, { - code: 'attachment-error', - message: 'fixture: image side exceeds the deployment limit', - details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, - }) - } - return err(request, { - code: 'agent-busy', - message: 'fixture: prompt rejected before acceptance', - details: { reason: 'fixture-prompt-rejection' }, - }) - } - summary.updatedAt = Date.now() - // First accepted prompt appends events: the summary stops being blank. - summary.blank = false - const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') - const durable: ContentBlock[] = content.map((block) => { - if (block.type === 'text') return block - const attachment: ImageAttachmentRef = { - attachmentId: `fixture:${randomUuid()}` as AttachmentIdType, - mediaType: block.mediaType, - bytes: Math.max( - 1, - Math.floor(block.data.length * 3 / 4) - - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0), - ), - width: 160, - height: 90, - ...block.name === undefined ? {} : { name: block.name }, - } - attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) - return { type: 'image', attachment } - }) - if (mode === 'steer' && replays.has(id)) { - // Steering: the durable user/message lands inside the current turn; the replay continues. - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) - return ok(request, { accepted: true as const }) - } - const turn = nextTurn.get(id) ?? 0 - nextTurn.set(id, turn + 1) - setRunning(id, true) - append(id, { type: 'turn/start', data: { turn } }) - // Boundary flush parallel (the host's step/start observer): an outstanding - // /plan selection commits as plan/mode inside the opened turn. - const plan = foldPlan(logOf(id)) - if (plan.wanted !== null && plan.wanted !== plan.active) { - append(id, { type: 'plan/mode', data: { active: plan.wanted } }) - } - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) - // Capacity parallel of the host token-meter's request/context record: - // log-only, appended inside the open turn, and deduplicated against the - // route already recorded (the fixture never varies contextWindow). - const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } - if (lastRequestContext(logOf(id))?.model !== selection.model) { - append(id, { - type: 'request/context', - data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 }, - }) - } - startReply( - id, - turn, - userText === 'render markdown' - ? MARKDOWN_FIXTURE - : userText === 'report model' - ? (() => { - const selection = modelSelections.get(id) - return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}` - + (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`) - })() - : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, - ) - return ok(request, { accepted: true as const }) - }, - attachment: (request) => { - const stored = attachments.get(String(request.payload.attachmentId)) - if (stored === undefined) { - return err(request, { - code: 'attachment-error', - message: 'fixture attachment missing', - details: { reason: 'ATTACHMENT_NOT_FOUND' }, - }) - } - if (!logReferencesAttachment( - logs.get(request.payload.sessionId) ?? [], - String(request.payload.attachmentId), - )) { - return err(request, { - code: 'attachment-error', - message: 'fixture attachment is not referenced by this session', - details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, - }) - } - return ok(request, stored) - }, - updateQueue: request => err(request, { - code: 'queue-item-not-found', - message: 'fixture has no pending queue item', - details: { itemId: request.payload.itemId }, - }), - cancel: (request) => { - const replay = replays.get(request.payload.sessionId) - if (replay !== undefined) { - clearTimeout(replay.timer) - replay.finish(true) - } else { - setRunning(request.payload.sessionId, false) - } - return ok(request, { accepted: true as const }) - }, + } + let cut = boundary.seq + 1 + while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ + const child: FixtureSessionSummary = { + sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, + parentSessionId: sessionId, + ...source.cwd === undefined ? {} : { cwd: source.cwd }, + } + logs.set(child.sessionId, log.slice(0, cut)) + sessions.push(child) + emitRemote('api-session/added', [child]) + const workspace = workspaces.find(w => w.sessionIds.includes(sessionId)) + if (workspace !== undefined) { + workspace.sessionIds = [child.sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + return sessionOk({ sessionId: child.sessionId }) }, + history: async (request) => { + const log = logs.get(request.sessionId) ?? [] + const throughSeq = request.throughSeq ?? log.length - 1 + const boundedLog = log.slice(0, throughSeq + 1) + // Snapshot at request time, then deliver after the transit delay. + const page = pageOf(boundedLog, request.beforeSeq, request.maxMessages ?? 50) + // Tail page carries the projections block (host parallel: one consistent + // cut over the registered units; asOfSeq = window tail seq, -1 on an + // empty log — the host's session.seq-1 convention). + const projections = request.beforeSeq === undefined + ? { asOfSeq: throughSeq, values: projectionValuesOf(boundedLog) } + : undefined + const doomed = failNextHistory + failNextHistory = false + const delay = historyDelayMs + if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) + if (doomed) throw new Error('fixture: simulated history transport failure') + return sessionOk({ ...page, ...projections === undefined ? {} : { projections } }) + }, + models: request => sessionOk({ + current: modelSelections.get(request.sessionId) + ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + // The fixture's routes all serve; a surface exercising the blocked + // posture drives it through its own stub. + routable: true, + groups: fixtureModelGroups(), + failures: [], + }), + selectModel: (request) => { + const selected: ModelSelection = { + provider: request.provider, + model: request.model, + ...request.reasoningEffort === undefined + ? {} + : { reasoningEffort: request.reasoningEffort }, + } + modelSelections.set(request.sessionId, selected) + return sessionOk({ selected }) + }, + prompt: (request) => { + const { sessionId: id, mode, content } = request + const summary = summaryOf(id) + if (summary === undefined) { + return sessionErr({ code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) + } + if (options.rejectPrompt) { + if (content.some(block => block.type === 'image')) { + return sessionErr({ + code: 'attachment-error', + message: 'fixture: image side exceeds the deployment limit', + details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, + }) + } + return sessionErr({ + code: 'agent-busy', + message: 'fixture: prompt rejected before acceptance', + details: { reason: 'fixture-prompt-rejection' }, + }) + } + summary.updatedAt = Date.now() + // First accepted prompt appends events: the summary stops being blank. + summary.blank = false + const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') + const durable: ContentBlock[] = content.map((block) => { + if (block.type === 'text') return block + const attachment: ImageAttachmentRef = { + attachmentId: `fixture:${randomUuid()}` as AttachmentIdType, + mediaType: block.mediaType, + bytes: Math.max( + 1, + Math.floor(block.data.length * 3 / 4) + - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0), + ), + width: 160, + height: 90, + ...block.name === undefined ? {} : { name: block.name }, + } + attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) + return { type: 'image', attachment } + }) + if (mode === 'steer' && replays.has(id)) { + // Steering: the durable user/message lands inside the current turn; the replay continues. + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + return sessionOk({ accepted: true as const }) + } + const turn = nextTurn.get(id) ?? 0 + nextTurn.set(id, turn + 1) + setRunning(id, true) + append(id, { type: 'turn/start', data: { turn } }) + // Boundary flush parallel (the host's step/start observer): an outstanding + // /plan selection commits as plan/mode inside the opened turn. + const plan = foldPlan(logOf(id)) + if (plan.wanted !== null && plan.wanted !== plan.active) { + append(id, { type: 'plan/mode', data: { active: plan.wanted } }) + } + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + // Capacity parallel of the host token-meter's request/context record: + // log-only, appended inside the open turn, and deduplicated against the + // route already recorded (the fixture never varies contextWindow). + const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } + if (lastRequestContext(logOf(id))?.model !== selection.model) { + append(id, { + type: 'request/context', + data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 }, + }) + } + startReply( + id, + turn, + userText === 'render markdown' + ? MARKDOWN_FIXTURE + : userText === 'report model' + ? (() => { + const selection = modelSelections.get(id) + return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}` + + (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`) + })() + : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, + ) + return sessionOk({ accepted: true as const }) + }, + attachment: (request) => { + const stored = attachments.get(String(request.attachmentId)) + if (stored === undefined) { + return sessionErr({ + code: 'attachment-error', + message: 'fixture attachment missing', + details: { reason: 'ATTACHMENT_NOT_FOUND' }, + }) + } + if (!logReferencesAttachment( + logs.get(request.sessionId) ?? [], + String(request.attachmentId), + )) { + return sessionErr({ + code: 'attachment-error', + message: 'fixture attachment is not referenced by this session', + details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, + }) + } + return sessionOk(stored) + }, + updateQueue: request => sessionErr({ + code: 'queue-item-not-found', + message: 'fixture has no pending queue item', + details: { itemId: request.itemId }, + }), + cancel: (request) => { + const replay = replays.get(request.sessionId) + if (replay !== undefined) { + clearTimeout(replay.timer) + replay.finish(true) + } else { + setRunning(request.sessionId, false) + } + return sessionOk({ accepted: true as const }) + }, + } + + const controlBaseline = (): Extract => { + const queues: Record = {} + const jobs: Record = {} + const projections: Record = {} + for (const summary of sessions) { + queues[summary.sessionId] = [] + jobs[summary.sessionId] = [] + const log = logs.get(summary.sessionId) ?? [] + projections[summary.sessionId] = { + asOfSeq: log.length - 1, + values: projectionValuesOf(log), + } + } + return { + type: 'baseline', + value: { + queues, + jobs, + approvals: [], + questions: [], + projections, + }, + } + } + + const approvalInvocation = (): FixtureRemoteEventInvocationFrame => ({ + type: 'waterfall', + event: 'approval/request', + eventId: pendingApprovalEventId, + agentId: sid('fx-alpha'), + request: { + toolName: 'dangerous_tool', + reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', + }, + }) + + const questionInvocation = (): FixtureRemoteEventInvocationFrame => ({ + type: 'waterfall', + event: 'user-questions/request', + eventId: pendingQuestionEventId, + agentId: sid('fx-alpha'), + request: { + questions: fixtureQuestions, + }, + }) + + async function* openControl(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const conn = new FxInbox() + controlConns.add(conn) + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + try { + yield controlBaseline() + yield* conn.drain(signal) + } finally { + streamBreakers.delete(breakNow) + controlConns.delete(conn) + } + } + + async function* openWorkspace(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const conn = new FxInbox() + workspaceConns.add(conn) + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + try { + yield workspaceBaseline() + yield* conn.drain(signal) + } finally { + streamBreakers.delete(breakNow) + workspaceConns.delete(conn) + } + } + + async function* openRemoteEvents( + signal: AbortSignal, + ): AsyncGenerator { + signal.throwIfAborted() + const clientId = randomUuid() + const conn = new FxInbox() + remoteEventConns.set(clientId, conn) + // Periodic material for the RPC-panel acceptance: flip fx-gamma every 5s. + // fx-gamma only; the conversation replay owns fx-alpha's running state. + const timer = setInterval(() => { + const gamma = summaryOf(sid('fx-gamma')) + /* v8 ignore next -- the fixture never removes fx-gamma. */ + if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) + }, 5000) + try { + yield { type: 'ready', clientId } + if (approvalPending) yield approvalInvocation() + if (questionPending) yield questionInvocation() + yield* conn.drain(signal) + } finally { + clearInterval(timer) + remoteEventConns.delete(clientId) + } + } + + async function* openFollow( + request: FixtureFollowRequest, + signal: AbortSignal, + ): AsyncGenerator { + signal.throwIfAborted() + const sessionId = request.address.kind === 'session' + ? request.address.sessionId + : request.address.childSessionId + if (summaryOf(sessionId) === undefined) throw new Error(`fixture: no session ${sessionId}`) + const conn = new FxInbox() + let conns = followConns.get(sessionId) + if (conns === undefined) { + conns = new Set() + followConns.set(sessionId, conns) + } + conns.add(conn) + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + const snapshot = [...logOf(sessionId)] + const cursor = snapshot.at(-1)?.seq ?? -1 + if (request.afterSeq !== undefined && request.afterSeq > cursor) { + throw new Error( + `fixture: session event resume seq ${String(request.afterSeq)} is past cursor ${String(cursor)}`, + ) + } + let nextSeq = (request.afterSeq ?? cursor) + 1 + try { + yield { type: 'opened', cursor } + if (request.afterSeq !== undefined) { + for (const event of snapshot) { + if (event.seq < nextSeq) continue + if (event.seq !== nextSeq) { + throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`) + } + nextSeq++ + const view = viewFor(event, snapshot) + yield view === undefined ? { type: 'event', event } : { type: 'event', event, view } + } + } + for await (const frame of conn.drain(signal)) { + if (frame.event.seq < nextSeq) continue + if (frame.event.seq !== nextSeq) { + throw new Error(`fixture: session event stream skipped seq ${String(nextSeq)}`) + } + nextSeq++ + yield frame + } + } finally { + streamBreakers.delete(breakNow) + conns.delete(conn) + if (conns.size === 0) followConns.delete(sessionId) + } + } + + const answerRemoteEvent = (result: FixtureRemoteEventResult): ConnectionRpcResult => { + if (!remoteEventConns.has(result.clientId)) { + return { + ok: false, + error: { + code: 'invocation-unavailable', + message: 'fixture Remote event result identifies no active event stream', + details: {}, + }, + } + } + if (result.eventId === pendingApprovalEventId) { + if (!approvalPending) return { ok: true, value: undefined } + approvalPending = false + } else if (result.eventId === pendingQuestionEventId) { + if (!questionPending) return { ok: true, value: undefined } + questionPending = false + } else { + return { ok: true, value: undefined } + } + emitRemoteFrame({ type: 'cancel', eventId: result.eventId }) + return { ok: true, value: undefined } + } + + const workspaceApi: FixtureWorkspaceApi = { + create: (request) => { + const existing = workspaces.find(workspace => workspace.path === request.path) + if (existing !== undefined) { + return sessionOk({ workspace: workspaceSnapshot(existing), created: false }) + } + const now = new Date().toISOString() + const created: FixtureWorkspace = { + workspaceId: wid(`fx-ws-${nextWorkspace++}`), + path: request.path, + title: request.path.split('/').filter(Boolean).at(-1) ?? request.path, + sessionIds: [], + createdAt: now, + updatedAt: now, + } + workspaces.unshift(created) + const workspace = workspaceSnapshot(created) + emitWorkspace({ type: 'upsert', workspace }) + return sessionOk({ workspace, created: true }) + }, + rename: (request) => { + const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) + if (workspace === undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, + }) + } + const title = request.title.trim() + if (title === '') { + return sessionErr({ + code: 'bad-request', + message: 'Workspace rename requires a non-blank title', + details: {}, + }) + } + if (title !== workspace.title) { + if (workspaces.some(candidate => candidate.workspaceId !== request.workspaceId && candidate.title === title)) { + return sessionErr({ + code: 'workspace-name-conflict', + message: `workspace name '${title}' is already in use`, + details: { name: title }, + }) + } + workspace.title = title + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + return sessionOk({ workspace: workspaceSnapshot(workspace) }) + }, + delete: (request) => { + const index = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) + if (index === -1) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, + }) + } + workspaces.splice(index, 1) + emitWorkspace({ type: 'remove', workspaceId: request.workspaceId }) + return sessionOk({ deleted: true }) + }, + insertBefore: (request) => { + const source = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) + const anchor = request.beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === request.beforeWorkspaceId) + const missing = source === -1 + ? request.workspaceId + : anchor === -1 + ? request.beforeWorkspaceId + : undefined + if (missing !== undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${missing}`, + details: { workspaceId: missing }, + }) + } + if (request.beforeWorkspaceId !== request.workspaceId) { + const previousOrder = workspaces.map(workspace => workspace.workspaceId) + const [workspace] = workspaces.splice(source, 1) + /* v8 ignore next -- source was resolved from the same array immediately above. */ + if (workspace === undefined) throw new Error(`fixture lost workspace ${request.workspaceId}`) + const at = request.beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(candidate => candidate.workspaceId === request.beforeWorkspaceId) + workspaces.splice(at, 0, workspace) + if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { + emitWorkspace({ + type: 'order', + workspaceIds: workspaces.map(candidate => candidate.workspaceId), + }) + } + } + return sessionOk({ workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) + }, + insertSessionBefore: (request) => { + const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) + if (workspace === undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, + }) + } + if (!workspace.sessionIds.includes(request.sessionId) + || (request.beforeSessionId !== undefined && !workspace.sessionIds.includes(request.beforeSessionId))) { + return sessionErr({ + code: 'workspace-move-invalid', + message: `session or anchor is not accounted by workspace ${request.workspaceId}`, + details: { + workspaceId: request.workspaceId, + sessionId: request.sessionId, + ...request.beforeSessionId === undefined ? {} : { beforeSessionId: request.beforeSessionId }, + }, + }) + } + const without = workspace.sessionIds.filter(id => id !== request.sessionId) + const at = request.beforeSessionId === undefined ? without.length : without.indexOf(request.beforeSessionId) + const sessionIds = [...without.slice(0, at), request.sessionId, ...without.slice(at)] + if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { + workspace.sessionIds = sessionIds + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + return sessionOk({ workspace: workspaceSnapshot(workspace) }) + }, + archiveSession: (request) => { + if (summaryOf(request.sessionId) === undefined) { + return sessionErr({ + code: 'session-not-found', + message: `no session ${request.sessionId}`, + details: { sessionId: request.sessionId }, + }) + } + if (!archivedSessionIds.includes(request.sessionId)) { + archivedSessionIds.push(request.sessionId) + emitWorkspace({ type: 'archived', archivedSessionIds: [...archivedSessionIds] }) + } + return sessionOk({ archivedSessionIds: [...archivedSessionIds] }) + }, + } + + const api: ApiProxy = { subagents: { list: request => ok(request, { entries: [], parentAvailable: true }), - history: (request) => { - const log = logs.get(request.payload.childSessionId) ?? [] - return Promise.resolve(ok( - request, - pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50), - )) - }, prompt: request => Promise.resolve(ok(request, { messageId: `fixture-message-${request.payload.childSessionId}` as never, })), @@ -2625,138 +3232,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, openPath: request => ok(request, { opened: true as const }), }, - workspace: { - list: request => ok(request, { - items: workspaces.map(w => ({ ...w })), - archivedSessionIds: [...archivedSessionIds], - }), - create: (request) => { - const { path } = request.payload - const existing = workspaces.find(w => w.path === path) - if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) - const now = new Date().toISOString() - const created: WorkspaceView = { - workspaceId: wid(`fx-ws-${nextWorkspace++}`), - path, - title: path.split('/').filter(Boolean).at(-1) ?? path, - sessionIds: [], - createdAt: now, - updatedAt: now, - } - workspaces.unshift(created) - emitHost({ type: 'host/workspace-changed', workspace: { ...created } }) - return ok(request, { workspace: { ...created }, created: true }) - }, - rename: (request) => { - const { workspaceId, title } = request.payload - const workspace = workspaces.find(w => w.workspaceId === workspaceId) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${workspaceId}`, - details: { workspaceId }, - }) - } - const trimmed = title.trim() - if (trimmed !== workspace.title) { - if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) { - return err(request, { - code: 'workspace-name-conflict', - message: `workspace name '${trimmed}' is already in use`, - details: { name: trimmed }, - }) - } - workspace.title = trimmed - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - return ok(request, { workspace: { ...workspace } }) - }, - delete: (request) => { - const { workspaceId } = request.payload - const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) - if (index === -1) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${workspaceId}`, - details: { workspaceId }, - }) - } - workspaces.splice(index, 1) - emitHost({ type: 'host/workspace-removed', workspaceId }) - return ok(request, { deleted: true as const }) - }, - insertBefore: (request) => { - const { workspaceId, beforeWorkspaceId } = request.payload - const source = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) - const anchor = beforeWorkspaceId === undefined - ? workspaces.length - : workspaces.findIndex(workspace => workspace.workspaceId === beforeWorkspaceId) - const missing = source === -1 ? workspaceId : anchor === -1 ? beforeWorkspaceId : undefined - if (missing !== undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${missing}`, - details: { workspaceId: missing }, - }) - } - if (beforeWorkspaceId !== workspaceId) { - const previousOrder = workspaces.map(candidate => candidate.workspaceId) - const [workspace] = workspaces.splice(source, 1) - /* v8 ignore next -- source was resolved from the same array immediately above. */ - if (workspace === undefined) throw new Error(`fixture lost workspace ${workspaceId}`) - const at = beforeWorkspaceId === undefined - ? workspaces.length - : workspaces.findIndex(candidate => candidate.workspaceId === beforeWorkspaceId) - workspaces.splice(at, 0, workspace) - if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { - emitHost({ - type: 'host/workspace-order-changed', - workspaceIds: workspaces.map(candidate => candidate.workspaceId), - }) - } - } - return ok(request, { workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) - }, - insertSessionBefore: (request) => { - const { workspaceId, sessionId, beforeSessionId } = request.payload - const workspace = workspaces.find(w => w.workspaceId === workspaceId) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${workspaceId}`, - details: { workspaceId }, - }) - } - if (!workspace.sessionIds.includes(sessionId) - || (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) { - return err(request, { - code: 'workspace-move-invalid', - message: `session or anchor is not accounted by workspace ${workspaceId}`, - details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } }, - }) - } - const without = workspace.sessionIds.filter(id => id !== sessionId) - const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) - const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] - if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { - workspace.sessionIds = sessionIds - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - return ok(request, { workspace: { ...workspace } }) - }, - archiveSession: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const { sessionId } = request.payload - if (!archivedSessionIds.includes(sessionId)) { - archivedSessionIds.push(sessionId) - emitHost({ type: 'host/archived-sessions-changed', archivedSessionIds: [...archivedSessionIds] }) - } - return ok(request, { archivedSessionIds: [...archivedSessionIds] }) - }, - }, agentPresets: { // Both trusts appear, because a surface must present a locally authored // preset differently from one the deployment vetted. @@ -2891,69 +3366,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ), ), }, - events: { - async *mux(_request, signal) { - const conn = new FxInbox() - muxConns.add(conn) - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - // Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds. - for (const s of sessions) { - if (!s.running) continue - const log = logs.get(s.sessionId) ?? [] - conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: log.length - 1 } }) - // Post-subscribe projection baseline (host parallel: recomputed unit values ride push frames). - const values = projectionValuesOf(log) - for (const key of Object.keys(values)) { - conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } }) - } - } - if (approvalPending) { - conn.push({ - rpcId: pendingApprovalRpcId, - payload: { - type: 'approval/requested', sessionId: sid('fx-alpha'), - approvalId: pendingApprovalId, - toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', - }, - }) - } - if (questionPending) { - conn.push({ - rpcId: pendingQuestionRpcId, - payload: { - type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions, - }, - }) - } - try { - yield* conn.drain(signal) - } finally { - streamBreakers.delete(breakNow) - muxConns.delete(conn) - } - }, - async *host(_request, signal) { - const conn = new FxInbox() - hostConns.add(conn) - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - // Periodic material (the RPC-panel acceptance's clear-then-new-frames step depends on it): flip fx-gamma every 5s. - // fx-gamma only: never touch fx-alpha's running semantics (the conversation replay drives that). - const timer = setInterval(() => { - const gamma = summaryOf(sid('fx-gamma')) - /* v8 ignore next -- the undefined arm needs fx-gamma deleted, but the fixture never removes sessions. */ - if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) - }, 5000) - try { - yield* conn.drain(signal) - } finally { - clearInterval(timer) - streamBreakers.delete(breakNow) - hostConns.delete(conn) - } - }, - }, settings: { // Only the resolved DeepSeek address needed by first-run readiness is // represented here. Fixture-backed journeys do not open its Models @@ -3024,31 +3436,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))), }), }, - respond(message: ClientResponse): Promise { - // Same routing discipline as the host: rpcId first, then the payload's - // audit correlation; a settled or unknown id is not-pending. - if (message.rpcId === pendingApprovalRpcId) { - if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' }) - if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) - const value = message.result.value as { approvalId?: unknown; outcome?: unknown } - if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - approvalPending = false - emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome }) - return Promise.resolve({ accepted: true }) - } - if (!questionPending || message.rpcId !== pendingQuestionRpcId) { - return Promise.resolve({ accepted: false, reason: 'not-pending' }) - } - questionPending = false - emitMux({ - type: 'question/resolved', sessionId: sid('fx-alpha'), - questionRpcId: pendingQuestionRpcId, - outcome: message.result.ok ? 'answered' : 'cancelled', - }) - return Promise.resolve({ accepted: true }) - }, // Satisfies the ApiProxy contract type only: the browser export button // hands GET /api/session.export to the native download manager, so this // stub is never reached through the fixture's dispatch. @@ -3058,48 +3445,126 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } const rpc: ClientConnectionRpc = { - call(channel, endpoint, payload) { + call(channel, endpoint, payload, signal) { if (channel !== '/api') { return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`)) } const args = (payload as { - args: { + args: Readonly<{ agentId: SessionId line?: string query?: string images?: readonly unknown[] ref?: { id: string; revision: number } - request?: { objective?: string; maxGoalRounds?: number } - } + request?: unknown + _request?: unknown + }> }).args const sessionId = args.agentId + const callSignal = signal ?? new AbortController().signal + const request = args.request switch (endpoint) { case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { - objective: args.request?.objective as string, - ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, + objective: (request as { objective?: string } | undefined)?.objective as string, + ...(request as { maxGoalRounds?: number } | undefined)?.maxGoalRounds === undefined + ? {} + : { maxGoalRounds: (request as { maxGoalRounds: number }).maxGoalRounds }, })) - case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {})) + case 'goals/edit': return Promise.resolve(goalRemotes.edit( + sessionId, + args.ref as FxGoalRef, + request as { objective?: string; maxGoalRounds?: number }, + )) case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef)) case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef)) case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef)) case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef)) + case 'session/list': return sessionApi.list( + args._request as Parameters[0], + ) + case 'session/search': return sessionApi.search( + request as Parameters[0], + callSignal, + ) + case 'session/create': return sessionApi.create( + request as Parameters[0], + ) + case 'session/models': return sessionApi.models( + request as Parameters[0], + ) + case 'session/selectModel': return sessionApi.selectModel( + request as Parameters[0], + ) + case 'session/rename': return sessionApi.rename( + request as Parameters[0], + ) + case 'session/fork': return sessionApi.fork( + request as Parameters[0], + ) + case 'session/prompt': return sessionApi.prompt( + request as Parameters[0], + ) + case 'session/attachment': return sessionApi.attachment( + request as Parameters[0], + ) + case 'session/updateQueue': return sessionApi.updateQueue( + request as Parameters[0], + ) + case 'session/cancel': return sessionApi.cancel( + request as Parameters[0], + ) + case 'session/page': { + const page = request as FixturePageRequest + const pageSessionId = page.address.kind === 'session' + ? page.address.sessionId + : page.address.childSessionId + return sessionApi.history({ + sessionId: pageSessionId, + throughSeq: page.throughSeq, + ...page.beforeSeq === undefined ? {} : { beforeSeq: page.beforeSeq }, + ...page.maxMessages === undefined ? {} : { maxMessages: page.maxMessages }, + }) + } + case '$events/result': return Promise.resolve(answerRemoteEvent(args as unknown as FixtureRemoteEventResult)) + case 'workspace/create': return workspaceApi.create(request as WorkspaceCreateRequest) + case 'workspace/rename': return workspaceApi.rename(request as WorkspaceRenameRequest) + case 'workspace/delete': return workspaceApi.delete(request as WorkspaceDeleteRequest) + case 'workspace/insertBefore': return workspaceApi.insertBefore(request as WorkspaceInsertBeforeRequest) + case 'workspace/insertSessionBefore': return workspaceApi.insertSessionBefore( + request as WorkspaceInsertSessionBeforeRequest, + ) + case 'workspace/archiveSession': return workspaceApi.archiveSession(request as WorkspaceArchiveSessionRequest) default: return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`)) } }, + open(channel, endpoint, payload, signal) { + if (channel !== '/api') { + throw new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`) + } + const args = (payload as { args: Readonly<{ request?: unknown }> }).args + switch (endpoint) { + case '$events': return openRemoteEvents(signal) + case 'session/control': return openControl(signal) + case 'session/follow': return openFollow(args.request as FixtureFollowRequest, signal) + case 'workspace/follow': return openWorkspace(signal) + default: + throw new Error(`fixture connection stream endpoint ${JSON.stringify(endpoint)} is unavailable`) + } + }, } return { api, rpc } } /** * Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it - * overrides the protocol-level virtuals (callUnary/openMux/openHost/respond) to dispatch - * straight into the in-memory ApiProxy — while still minting rpcIds, fabricating the four - * named full forms, and feeding the same tap as a real carrier. TODO: delete when the fixture + * overrides the legacy protocol-level call virtual to dispatch + * straight into the in-memory ApiProxy while still minting rpcIds, fabricating + * the request/response envelopes, and feeding the same tap as a real carrier. TODO: delete when the fixture * moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)). */ export class FixtureApiClient extends AbstractApiClient { @@ -3143,20 +3608,7 @@ export class FixtureApiClient extends AbstractApiClient { signal: AbortSignal, ): Promise> { switch (method) { - case 'session.list': return this.api.sessions.list(request) - case 'session.search': return this.api.sessions.search(request, signal) - case 'session.create': return this.api.sessions.create(request) - case 'session.history': return this.api.sessions.history(request) - case 'session.models': return this.api.sessions.models(request) - case 'session.selectModel': return this.api.sessions.selectModel(request) - case 'session.rename': return this.api.sessions.rename(request) - case 'session.fork': return this.api.sessions.fork(request) - case 'session.prompt': return this.api.sessions.prompt(request) - case 'session.attachment': return this.api.sessions.attachment(request) - case 'session.updateQueue': return this.api.sessions.updateQueue(request) - case 'session.cancel': return this.api.sessions.cancel(request) case 'subagent.list': return this.api.subagents.list(request) - case 'subagent.history': return this.api.subagents.history(request) case 'subagent.prompt': return this.api.subagents.prompt(request, signal) case 'subagent.interrupt': return this.api.subagents.interrupt(request) case 'host.describe': return this.api.host.describe(request) @@ -3164,13 +3616,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) - case 'workspace.list': return this.api.workspace.list(request) - case 'workspace.create': return this.api.workspace.create(request) - case 'workspace.rename': return this.api.workspace.rename(request) - case 'workspace.delete': return this.api.workspace.delete(request) - case 'workspace.insertBefore': return this.api.workspace.insertBefore(request) - case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) - case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.select': return this.api.agentPresets.select(request) @@ -3198,46 +3643,6 @@ export class FixtureApiClient extends AbstractApiClient { } } - protected override openMux( - payload: { since?: Record }, - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.tapStream(this.api.events.mux(rpcRequest(payload), signal), onOpen) - } - - protected override openHost( - payload: Record, - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.tapStream(this.api.events.host(rpcRequest(payload), signal), onOpen) - } - - private async *tapStream( - stream: AsyncIterable>, - onOpen?: () => void, - ): AsyncGenerator> { - // No HTTP here: the in-memory stream is established the moment iteration starts (mirrors - // readSse firing onOpen after response headers, before any frame). - onOpen?.() - for await (const envelope of stream) { - const full: ServerRequest = { type: 'server-request', rpcId: envelope.rpcId, method: envelope.payload.type, payload: envelope.payload } - this.onEnvelope(full) - yield envelope - } - } - - /** - * Deliver a client response to the in-memory contract impl (no HTTP POST), - * echoing the envelope to the observation tap like every other path. - * @param message - the client-response envelope answering a server request. - * @returns the carrier receipt from the fixture impl. - */ - override async respond(message: ClientResponse): Promise { - this.onEnvelope(message) - return this.api.respond(message) - } } /** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */ diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 9631881505..c56bf17c84 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,30 +1,44 @@ /** * Browser wire client. The plugin selects fixture or HTTP transport, provides - * the shared API client, and lets the runtime object layer start the stream - * controller with its sinks. + * the shared API client, and lets API Gateway own the connection loop. */ import type { Context } from '@deepseek-ai/cordis' import type { HostDescription, IApiClient } from './api.ts' -import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' +import { + ConnectionController, + type ConnectionConfig, + type ConnectionGenerationSource, + type ConnectionSinks, + type ConnectionState, +} from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' -import { createWebConnectionRpc, type RpcFetch } from './rpc.ts' +import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * A connection generation was established. Wire-derived caches must + * repull; long-lived streams own their own resume and baseline lifecycle. + * @mode emit + */ + 'connection/reset'(): void + } +} + // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { - ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApiProxy, HostApi, DirectoryEntry, DirectoryListing, - ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, + ToolCallView, ToolResultView, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, + MessageId, ModelReasoningEffort, ModelSelection, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, - JobView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, - ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, + ClientRequest, ServerResponse, RpcMessage, HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, GoalsApi, GoalRef, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, @@ -38,8 +52,10 @@ export { // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. -export type { ConnectionConfig, ConnectionSinks, ConnectionState } -export type { ClientConnectionRpc } from '../rpc.ts' +export type { ConnectionConfig, ConnectionGenerationSource, ConnectionSinks, ConnectionState } +export type { + ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, +} from '../rpc.ts' export type { RpcFetch } from './rpc.ts' /** Observable Host description published by each completed connection handshake. */ @@ -64,6 +80,8 @@ export interface ClientTransportHooks { createApiClient(): IApiClient /** Transport for generic unary RPC channels (the Typert gateway). */ fetch: RpcFetch + /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */ + openStream?: RpcStreamOpen /** * Bundle transport for the module system, present when the carrier also owns * bundle bytes (the worker tunnel). Absent in the served web app, whose @@ -87,9 +105,9 @@ interface ClientTransportGlobal { } /** - * The ctx.connection service API: the API client plus a one-shot - * controller starter (the runtime plugin supplies sinks when its object layer - * is ready — connection stays consumer-agnostic). + * The ctx.connection service API: the API client plus a one-shot controller + * starter. API Gateway supplies generation readiness and reset callbacks; + * Connection stays independent of downstream domain state. */ export interface ConnectionHandle { /** Shared api client (fixture or real, decided at boot from the page URL). */ @@ -105,16 +123,28 @@ export interface ConnectionHandle { /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc /** - * Start the connect/pump/reconnect loop with the consumer's frame sinks. - * One consumer owns the streams (the runtime object layer); a second call - * throws. - * @param sinks - frame/state callbacks. + * Register the sole source defining Host generations. The source reports + * ready only after its incremental listeners are attached. + * @param source - long-lived generation source owned by the push carrier. + * @returns disposer withdrawing the source and stopping an active loop. + */ + registerGenerationSource(source: ConnectionGenerationSource): () => void + /** + * Start the connect/reconnect loop with the consumer's state callbacks. + * API Gateway owns the loop; a second call throws. + * @param sinks - connection-state callbacks. * @param config - reconnect/backoff tunables. * @returns stop handle for the loop. */ start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void } } +interface ConnectionOwner { + readonly token: object + readonly source: ConnectionGenerationSource + readonly controller: ConnectionController +} + /** * Client plugin body: pick the api by page mode and provide ctx.connection. * @param ctx - client cordis context. @@ -125,8 +155,9 @@ export function apply(ctx: Context): void { const fixtureClient = fixture ? new FixtureApiClient() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient() - const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch) - let started = false + const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + let generationSource: ConnectionGenerationSource | undefined + let owner: ConnectionOwner | undefined let description: HostDescription | undefined const descriptionListeners = new Set<() => void>() const publishDescription = (next: HostDescription | undefined): void => { @@ -136,10 +167,16 @@ export function apply(ctx: Context): void { try { listener() } catch (error) { - console.error('[web-runtime] host-description listener threw:', error) + console.error('[connection] host-description listener threw:', error) } } } + const releaseOwner = (current: ConnectionOwner): void => { + if (owner !== current) return + owner = undefined + current.controller.stop() + publishDescription(undefined) + } const handle: ConnectionHandle = { api, isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), @@ -151,10 +188,25 @@ export function apply(ctx: Context): void { }, }, rpc, + registerGenerationSource(source) { + if (generationSource !== undefined) { + throw new Error('connection: a generation source is already registered') + } + generationSource = source + return () => { + if (generationSource !== source) return + generationSource = undefined + const current = owner + if (current?.source === source) releaseOwner(current) + } + }, start(sinks, config) { - if (started) throw new Error('connection: the stream loop is already owned by another consumer') - started = true - const controller = new ConnectionController(api, { + if (owner !== undefined) throw new Error('connection: the stream loop is already owned by another consumer') + const source = generationSource + if (source === undefined) throw new Error('connection: no generation source is registered') + const token = {} + const ownsGeneration = (): boolean => owner?.token === token + const controller = new ConnectionController(api, source, { ...sinks, onConnected: (next) => { publishDescription(next) @@ -162,20 +214,20 @@ export function apply(ctx: Context): void { // case publishDescription(undefined) has already retracted this // generation, so do not leak its stale connected notification to // the consumer sink afterward. - if (!Object.is(description, next)) return + if (!ownsGeneration() || !Object.is(description, next)) return sinks.onConnected?.(next) }, onStateChange: (state) => { if (state === 'reconnecting') publishDescription(undefined) + if (!ownsGeneration()) return sinks.onStateChange?.(state) }, }, config ?? {}) + const current = { token, source, controller } + owner = current controller.start() return { - stop: () => { - controller.stop() - publishDescription(undefined) - }, + stop: () => { releaseOwner(current) }, } }, } diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index 8781b3ee34..c7b609c01b 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -2,10 +2,10 @@ import { RpcId, - serverResponseSchema, type ClientRequest, + type RpcId as RpcIdType, } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { ClientConnectionRpc } from '../rpc.ts' +import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts' import { randomUuid } from './random-uuid.ts' const INTERNAL_BASE = 'http://dsh.internal' @@ -15,12 +15,20 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ /** Transport this caller posts through; same signature as the global `fetch`. */ export type RpcFetch = (input: URL, init: RequestInit) => Promise +/** Worker-local opener for decoded Gateway Remote streams. */ +export type RpcStreamOpen = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => AsyncIterable + /** * Create the browser-backed generic RPC caller. * @param doFetch - transport override; defaults to the page's global fetch. + * @param openStream - optional worker-local Gateway stream carrier. * @returns caller that owns request correlation and response-envelope validation. */ -export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc { +export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStreamOpen): ClientConnectionRpc { const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init)) return { async call(channel, endpoint, payload, signal) { @@ -44,15 +52,59 @@ export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc if (!response.ok) { throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`) } - const full = serverResponseSchema.parse(await response.json()) + const full = parseConnectionResponse(await response.json()) if (full.rpcId !== rpcId) { throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`) } return full.result }, + ...openStream === undefined ? {} : { + open(channel, endpoint, payload, signal) { + assertTarget(channel, endpoint) + if (channel !== '/api') { + throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`) + } + return openStream(endpoint, payload, signal) + }, + }, } } +function parseConnectionResponse(value: unknown): { + readonly rpcId: RpcIdType + readonly result: ConnectionRpcResult +} { + if (!isRecord(value) || value.type !== 'server-response' || typeof value.rpcId !== 'string') { + throw new TypeError('connection: invalid server-response envelope') + } + const result = value.result + if (!isRecord(result)) throw new TypeError('connection: invalid server-response result') + if (result.ok === true) { + return { + rpcId: RpcId(value.rpcId), + result: { ok: true, value: result.value }, + } + } + if (result.ok !== false || !isRecord(result.error)) { + throw new TypeError('connection: invalid server-response result') + } + const error = result.error + if (typeof error.code !== 'string' || typeof error.message !== 'string' || !isRecord(error.details)) { + throw new TypeError('connection: invalid server-response failure') + } + return { + rpcId: RpcId(value.rpcId), + result: { + ok: false, + error: { code: error.code, message: error.message, details: error.details }, + }, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function resolveBase(): string { const location = (globalThis as { location?: { origin?: string } }).location return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE diff --git a/packages/client/connection/src/client/web-api-client.ts b/packages/client/connection/src/client/web-api-client.ts index a2c2d95b7b..6716f252a5 100644 --- a/packages/client/connection/src/client/web-api-client.ts +++ b/packages/client/connection/src/client/web-api-client.ts @@ -1,91 +1,10 @@ -/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */ +/** Browser API carrier for unary HTTP calls. */ -import type { ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest } from './api.ts' import { AbstractApiClient } from './api.ts' -import { hostFrameSchema, muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema' -import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema' -import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../api-path.ts' -type SocketItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } -type Parser = { parse(value: unknown): F } - -/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */ +/** Browser platform subclass supplying fetch for unary calls. */ export class WebApiClient extends AbstractApiClient { protected doFetch(input: URL, init?: RequestInit): Promise { return globalThis.fetch(input, init) } - - protected override openMux( - _payload: Parameters[0]['payload'], - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen) - } - - protected override openHost( - _payload: Parameters[0]['payload'], - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen) - } - - private async *readWebSocket( - path: string, - signal: AbortSignal, - frameSchema: Parser, - onOpen?: () => void, - ): AsyncGenerator> { - const url = new URL(path, this.resolveBase()) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - const socket = new WebSocket(url) - const inbox: SocketItem[] = [] - let wake: (() => void) | undefined - const enqueue = (item: SocketItem): void => { - inbox.push(item) - wake?.() - wake = undefined - } - const handleOpen = (): void => { onOpen?.() } - const handleMessage = (event: MessageEvent): void => { - let full: ServerRequest - let frame: F - try { - if (typeof event.data !== 'string') throw new Error('binary WebSocket frame') - full = serverRequestSchema.parse(JSON.parse(event.data)) - frame = frameSchema.parse(full.payload) - } catch (error) { - console.error(`[client-connection] dropping malformed WebSocket frame on ${path}:`, error) - return - } - this.onEnvelope(full) - enqueue({ kind: 'frame', envelope: { rpcId: full.rpcId, payload: frame } }) - } - const handleClose = (): void => { enqueue({ kind: 'end' }) } - const handleAbort = (): void => { - if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close() - } - socket.addEventListener('open', handleOpen) - socket.addEventListener('message', handleMessage) - socket.addEventListener('close', handleClose, { once: true }) - signal.addEventListener('abort', handleAbort, { once: true }) - if (signal.aborted) handleAbort() - try { - while (true) { - while (inbox.length > 0) { - const item = inbox.shift() as SocketItem - if (item.kind === 'end') return - yield item.envelope - } - await new Promise((resolve) => { wake = resolve }) - } - } finally { - signal.removeEventListener('abort', handleAbort) - socket.removeEventListener('open', handleOpen) - socket.removeEventListener('message', handleMessage) - socket.removeEventListener('close', handleClose) - handleAbort() - } - } } diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 07fc0fc5da..b404e65103 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -23,7 +23,7 @@ export interface FetchHandler { /** * Bridge one node:http request to the fetch-shaped handler (client close - * aborts; SSE bodies stream out chunk by chunk). + * aborts; response bodies stream out chunk by chunk). * @param req - incoming node:http request (fully read before dispatch). * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. @@ -38,8 +38,8 @@ export async function bridge( const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is - // fully consumed (immediately for a bodyless GET), which would abort every SSE - // stream right after open. ServerResponse 'close' fires on connection teardown; + // fully consumed (immediately for a bodyless GET), which would abort a + // streaming response right after open. ServerResponse 'close' fires on connection teardown; // writableEnded distinguishes a normal end() from the client going away. res.on('close', () => { if (!res.writableEnded) abort.abort() @@ -80,7 +80,7 @@ export async function bridge( } for await (const chunk of response.body) { // Backpressure: a false return means the socket buffer is full — wait for drain - // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also + // instead of buffering unboundedly (slow or suspended consumers). 'close' also // resolves so a mid-wait disconnect can't park this loop forever; the close // handler above aborts the handler stream, which then ends the iteration. if (!res.write(chunk)) { diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index a1764a3d58..1944e09722 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -3,25 +3,27 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-attachment' // Activates the webServer Context merge used below. -import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' +import { API_PATH } from './api-path.ts' import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' import { HostConnectionService } from './rpc-host.ts' -import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' export type { ConnectionRpcAuthority, ConnectionRpcEndpointMatcher, + ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRpcHandlerOptions, + ConnectionRpcResult, + ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc, } from './rpc.ts' export { HostConnectionService } from './rpc-host.ts' -export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' +export { API_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' @@ -147,12 +149,6 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { && !isTrustedApiRequest(request, [])) { return new Response('forbidden', { status: 403 }) } - if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { - return new Response('upgrade required', { - status: 426, - headers: { connection: 'Upgrade', upgrade: 'websocket' }, - }) - } const apiProxy = ctx.get('apiProxy') if (apiProxy === undefined) return new Response('not found', { status: 404 }) return toFetchHandler(apiProxy).fetch(request) @@ -171,26 +167,5 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { }, } ctx.effect(() => ctx.webServer.register(route), 'client-connection: /api route') - ctx.inject(['apiProxy'], (apiCtx) => { - assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) - const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) - const registerDownlink = ( - path: string, - handle: WebUpgradeRoute['handler'], - ): void => { - apiCtx.effect(() => apiCtx.webServer.registerUpgrade({ - path, - handler: (req, socket, head) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - rejectWebSocketUpgrade(socket) - return - } - return handle(req, socket, head) - }, - }), `client-connection: ${path} WebSocket`) - } - apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') - registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) - registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) - }) + ctx.inject(['apiProxy'], (apiCtx) => { assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) }) } diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 0da66c85a7..162045ed0d 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -9,7 +9,6 @@ import { type RpcError, type RpcErrorDetailsMap, type RpcId as RpcIdType, - type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' @@ -18,6 +17,9 @@ import type { ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcHandlerOptions, + ConnectionRpcResult, + ConnectionRpcAuthority, + ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc, } from './rpc.ts' @@ -32,6 +34,12 @@ interface ConnectionRpcInterceptor { readonly options: ConnectionRpcHandlerOptions } +interface ConnectionServerResponse { + readonly type: 'server-response' + readonly rpcId: RpcIdType + readonly result: ConnectionRpcResult +} + declare module '@deepseek-ai/cordis' { interface Context { /** Host Connection transport and RPC registrations. */ @@ -62,6 +70,11 @@ export class HostConnectionService extends Service implements HostConnectionHand } } + /** Apply the existing configured request trust policy to a sibling Web route. */ + isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean { + return isTrustedApiRequest(request, authority === 'loopback' ? [] : this.trustedHosts) + } + /** * Compose one shared-channel Fetch handler from its interceptor and fallback. * @param channel - shared channel mounted by Connection. @@ -212,8 +225,8 @@ function errorResponse(rpcId: RpcIdType, error: RpcError): Response { return fullResponse(rpcId, { ok: false, error }) } -function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response { - const body: RpcServerResponse = { type: 'server-response', rpcId, result } +function fullResponse(rpcId: RpcIdType, result: ConnectionRpcResult): Response { + const body: ConnectionServerResponse = { type: 'server-response', rpcId, result } return Response.json(body) } diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index e1260f00e8..e8dc585d38 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,6 +1,22 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ -import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' +/** Carrier-neutral failure returned by one logical RPC endpoint. */ +export interface ConnectionRpcFailure { + readonly code: string + readonly message: string + readonly details: object +} + +/** Carrier-neutral result returned by one logical RPC endpoint. */ +export type ConnectionRpcResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ConnectionRpcFailure } + +/** HTTP request facts consumed by the existing browser trust fence. */ +export interface ConnectionTrustRequest { + /** Request headers supplied by either the Fetch or node:http representation. */ + readonly headers: Headers | Readonly> +} /** Trust fence applied before a Host RPC channel reaches its handler. */ export type ConnectionRpcAuthority = 'trusted-host' | 'loopback' @@ -16,7 +32,7 @@ export type ConnectionRpcHandler = ( endpoint: string, payload: unknown, signal: AbortSignal, -) => Promise> +) => Promise> /** Synchronous ownership test for one endpoint on a shared RPC channel. */ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean @@ -56,6 +72,14 @@ export interface HostConnectionRpc { export interface HostConnectionHandle { /** Generic RPC channel registry. */ readonly rpc: HostConnectionRpc + + /** + * Apply Connection's configured browser trust policy to another Web route. + * @param request - request headers from the HTTP or upgrade request. + * @param authority - configured trusted hosts or loopback-only policy. + * @returns whether the route may accept the request. + */ + isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean } /** Client caller for logical RPC channels carried by the current transport. */ @@ -66,12 +90,28 @@ export interface ClientConnectionRpc { * @param endpoint - channel-relative endpoint such as `goals/create`. * @param payload - channel-owned request payload. * @param signal - optional caller cancellation. - * @returns the existing RPC success/error result; correlation stays inside Connection. + * @returns the endpoint-owned success/error result; correlation stays inside Connection. */ call( channel: string, endpoint: string, payload: unknown, signal?: AbortSignal, - ): Promise> + ): Promise> + + /** + * Open an in-process logical stream when the selected carrier supplies one. + * Browser transports omit this method; API Gateway owns their WebSocket mux. + * @param channel - absolute logical channel such as `/api`. + * @param endpoint - channel-relative endpoint such as `session/follow`. + * @param payload - channel-owned request payload. + * @param signal - caller cancellation for this logical stream. + * @returns decoded stream values from the in-process carrier. + */ + readonly open?: ( + channel: string, + endpoint: string, + payload: unknown, + signal: AbortSignal, + ) => AsyncIterable } diff --git a/packages/client/connection/src/websocket-downlink.ts b/packages/client/connection/src/websocket-downlink.ts deleted file mode 100644 index 72ae5e94ef..0000000000 --- a/packages/client/connection/src/websocket-downlink.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** Host-side WebSocket carrier for the two server-to-browser event streams. */ - -import { randomUUID } from 'node:crypto' -import type { IncomingMessage } from 'node:http' -import type { Duplex } from 'node:stream' -import WebSocket, { WebSocketServer } from 'ws' -import type { - ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, -} from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' - -type Frame = MuxFrame | HostFrame - -function serverRequest(frame: RpcRequest): ServerRequest { - return { - type: 'server-request', - rpcId: frame.rpcId, - method: frame.payload.type, - payload: frame.payload, - } -} - -function send(socket: WebSocket, frame: RpcRequest): Promise { - return new Promise((resolve, reject) => { - if (socket.readyState !== WebSocket.OPEN) { - reject(new Error('websocket downlink closed before frame delivery')) - return - } - socket.send(JSON.stringify(serverRequest(frame)), (error) => { - if (error) reject(error) - else resolve() - }) - }) -} - -function failureFrame(error: unknown): RpcRequest { - return { - rpcId: RpcId(randomUUID()), - payload: { - type: 'stream/error', - error: { code: 'internal', message: String(error), details: {} }, - }, - } -} - -/** - * Owns WebSocket negotiation and frame pumping for the connection plugin's - * two downlinks. Client messages are a protocol violation: upstream traffic - * remains on HTTP. - */ -export class WebSocketDownlinks { - private readonly server = new WebSocketServer({ noServer: true }) - private readonly pumps = new Set>() - - /** @param api - host API supplying the typed event streams. */ - constructor(private readonly api: ApiProxy) {} - - /** - * Upgrade one socket and pump the mux stream until either side closes. - * @param req - HTTP upgrade request. - * @param socket - Raw socket transferred by the HTTP server. - * @param head - Bytes already read after the upgrade headers. - */ - handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void { - this.upgrade(req, socket, head, signal => this.api.events.mux({ - rpcId: RpcId(randomUUID()), - payload: {}, - }, signal)) - } - - /** - * Upgrade one socket and pump the host stream until either side closes. - * @param req - HTTP upgrade request. - * @param socket - Raw socket transferred by the HTTP server. - * @param head - Bytes already read after the upgrade headers. - */ - handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void { - this.upgrade(req, socket, head, signal => this.api.events.host({ - rpcId: RpcId(randomUUID()), - payload: {}, - }, signal)) - } - - /** - * Terminate owned sockets and await the no-server acceptor plus frame pumps. - * @returns A promise resolving after every socket and source iterator stops. - */ - async close(): Promise { - for (const socket of this.server.clients) socket.terminate() - await new Promise((resolve, reject) => { - this.server.close((error) => { - if (error === undefined) resolve() - else reject(error) - }) - }) - await Promise.all(this.pumps) - } - - private upgrade( - req: IncomingMessage, - socket: Duplex, - head: Buffer, - open: (signal: AbortSignal) => AsyncIterable>, - ): void { - this.server.handleUpgrade(req, socket, head, (websocket) => { - const abort = new AbortController() - websocket.once('close', () => { abort.abort() }) - websocket.once('error', () => { abort.abort() }) - websocket.once('message', () => { - websocket.close(1008, 'downlink only') - }) - const pump = this.pump(websocket, open(abort.signal), abort) - this.pumps.add(pump) - void pump.then(() => { this.pumps.delete(pump) }) - }) - } - - private async pump( - socket: WebSocket, - frames: AsyncIterable>, - abort: AbortController, - ): Promise { - try { - for await (const frame of frames) await send(socket, frame) - } catch (error) { - if (!abort.signal.aborted) { - try { - await send(socket, failureFrame(error)) - } catch { - // Socket loss won the race; no downstream remains to receive the failure frame. - } - } - } finally { - abort.abort() - if (socket.readyState === WebSocket.OPEN) socket.close() - } - } -} - -/** - * Reject an untrusted upgrade before protocol negotiation. - * @param socket - Raw HTTP socket that remains owned by the caller. - */ -export function rejectWebSocketUpgrade(socket: Duplex): void { - socket.end([ - 'HTTP/1.1 403 Forbidden', - 'Connection: close', - 'Content-Type: text/plain; charset=utf-8', - 'Content-Length: 9', - '', - 'forbidden', - ].join('\r\n')) -} diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index b7f6ebe389..443d398eba 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -1,59 +1,57 @@ /** * Connection plugin browser-half apply: ctx.connection handle mounting, mode - * selection off the page URL, and the single-consumer stream-loop ownership. + * selection off the page URL, and single-consumer connection-loop ownership. */ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { apply, type ConnectionHandle } from '../src/client/index.ts' -import type { RpcMessage } from '../src/client/api.ts' -import { RpcId } from '../src/client/api.ts' +import { + apply, + type ClientTransportHooks, + type ConnectionGenerationSource, + type ConnectionHandle, +} from '../src/client/index.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { hostname: string; search: string; origin?: string } } -type WebSocketGlobal = { WebSocket?: typeof WebSocket } - -const originalWebSocket = globalThis.WebSocket -const sockets: FakeWebSocket[] = [] - -class FakeWebSocket extends EventTarget { - static readonly CONNECTING = 0 - static readonly OPEN = 1 - static readonly CLOSING = 2 - static readonly CLOSED = 3 - - readonly url: string - readyState = FakeWebSocket.CONNECTING - - constructor(url: string | URL) { - super() - this.url = String(url) - sockets.push(this) - queueMicrotask(() => { - if (this.readyState !== FakeWebSocket.CONNECTING) return - this.readyState = FakeWebSocket.OPEN - this.dispatchEvent(new Event('open')) - }) - } - - close(): void { - if (this.readyState === FakeWebSocket.CLOSED) return - this.readyState = FakeWebSocket.CLOSED - this.dispatchEvent(new Event('close')) - } - - receive(data: unknown): void { - this.dispatchEvent(new MessageEvent('message', { data })) - } +type Win = { + location?: { hostname: string; search: string; origin?: string } + __DSH_TRANSPORT__?: ClientTransportHooks } afterEach(() => { delete (globalThis as Win).location - sockets.length = 0 - if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket - else globalThis.WebSocket = originalWebSocket + delete (globalThis as Win).__DSH_TRANSPORT__ }) +class GenerationProbe { + private readonly active = new Set<() => void>() + + readonly source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) return + settled = true + signal.removeEventListener('abort', finish) + this.active.delete(finish) + resolve() + } + this.active.add(finish) + signal.addEventListener('abort', finish, { once: true }) + ready() + if (signal.aborted) finish() + }) + + end(): void { + for (const finish of [...this.active]) finish() + } +} + +function installGeneration(handle: ConnectionHandle): GenerationProbe { + const probe = new GenerationProbe() + handle.registerGenerationSource(probe.source) + return probe +} + async function mount(): Promise { const ctx = new Context() await ctx.plugin({ apply, inject: [] }) @@ -84,9 +82,33 @@ describe('connection client apply', () => { expect((await mount()).isLoopback).toBe(false) }) - it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => { + it('requires one generation source and ignores a stale source disposer', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() + const first = new GenerationProbe() + const second = new GenerationProbe() + + expect(() => handle.start({})).toThrow('no generation source is registered') + const unregisterFirst = handle.registerGenerationSource(first.source) + expect(() => { handle.registerGenerationSource(second.source) }) + .toThrow('a generation source is already registered') + unregisterFirst() + const unregisterSecond = handle.registerGenerationSource(second.source) + unregisterFirst() + + const loop = handle.start({}) + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + unregisterSecond() + expect(handle.hostDescription.getSnapshot()).toBeUndefined() + loop.stop() + }) + + it('start() hands out one loop, rejects a second consumer, and stop() aborts the generation', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + installGeneration(handle) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const descriptions: Array = [] const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') }) @@ -111,9 +133,33 @@ describe('connection client apply', () => { errorSpy.mockRestore() }) + it('allows a replacement owner and ignores the previous owner handle', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + const generation = installGeneration(handle) + + const first = handle.start({}) + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + first.stop() + expect(handle.hostDescription.getSnapshot()).toBeUndefined() + + const second = handle.start({}) + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + first.stop() + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + + second.stop() + generation.end() + }) + it('does not announce a generation synchronously stopped by a description subscriber', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() + installGeneration(handle) const owner: { loop?: ReturnType } = {} let sawDescription = false const stopDescription = handle.hostDescription.subscribe(() => { @@ -137,6 +183,7 @@ describe('connection client apply', () => { it('retracts the host description while reconnecting and republishes the next generation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() + const generation = installGeneration(handle) const descriptions: Array = [] const reconnectSnapshots: Array = [] const stopDescription = handle.hostDescription.subscribe(() => { @@ -149,16 +196,12 @@ describe('connection client apply', () => { reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath) } }, - }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 }) + }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) try { await vi.waitFor(() => { expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) }) - const timing = (globalThis as Record).__fxTiming as - | { breakStreams(): void } - | undefined - if (timing === undefined) throw new Error('fixture timing hooks missing') - timing.breakStreams() + generation.end() await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) }) await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) }) @@ -170,7 +213,40 @@ describe('connection client apply', () => { } }) - it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => { + it('does not announce reconnecting after a description subscriber stops the loop', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + const generation = installGeneration(handle) + const owner: { loop?: ReturnType } = {} + let stoppedOnRetraction = false + const stopDescription = handle.hostDescription.subscribe(() => { + if (handle.hostDescription.getSnapshot() !== undefined || owner.loop === undefined) return + stoppedOnRetraction = true + owner.loop.stop() + }) + const states: string[] = [] + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const loop = handle.start({ + onStateChange: (state) => { states.push(state) }, + }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) + owner.loop = loop + try { + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + generation.end() + + await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) }) + expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(states).toEqual(['connected']) + } finally { + stopDescription() + loop.stop() + warnSpy.mockRestore() + } + }) + + it('WebApiClient keeps unary calls on globalThis.fetch', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch @@ -182,103 +258,10 @@ describe('connection client apply', () => { try { // Schema rejection is fine — the transport hop is the assertion. await (handle.api as WebApiClient).host.describe({}).catch(() => undefined) - await handle.api.respond({ - type: 'client-response', - rpcId: RpcId('response-over-http'), - result: { ok: true, value: {} }, - }).catch(() => undefined) } finally { globalThis.fetch = original } expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true) - expect(seen.some(u => u.includes('/api/respond'))).toBe(true) - }) - - it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => { - ;(globalThis as Win).location = { - hostname: 'localhost', search: '', origin: 'http://localhost:3080', - } - ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket - const fetch = vi.spyOn(globalThis, 'fetch') - const client = (await mount()).api as WebApiClient - const envelopes: RpcMessage[][] = [] - client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) }) - const opened: string[] = [] - const muxAbort = new AbortController() - const hostAbort = new AbortController() - const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]() - const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]() - const muxFrame = mux.next() - const hostFrame = host.next() - await vi.waitFor(() => { expect(sockets).toHaveLength(2) }) - expect(sockets.map(socket => socket.url)).toEqual([ - 'ws://localhost:3080/api/events.mux', - 'ws://localhost:3080/api/events.host', - ]) - await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) }) - - const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - sockets[0]!.receive(new Uint8Array([1, 2, 3])) - sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} })) - sockets[0]!.receive(JSON.stringify({ - type: 'server-request', - rpcId: 'mux-browser', - method: 'session/subscribed', - payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 }, - })) - sockets[1]!.receive(JSON.stringify({ - type: 'server-request', - rpcId: 'host-browser', - method: 'host/remote-event', - payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, - })) - expect(await muxFrame).toMatchObject({ - value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } }, - }) - expect(await hostFrame).toMatchObject({ - value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } }, - }) - expect(errors).toHaveBeenCalledTimes(2) - await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) }) - expect(fetch).not.toHaveBeenCalled() - - const muxEnd = mux.next() - const hostEnd = host.next() - muxAbort.abort() - hostAbort.abort() - await expect(muxEnd).resolves.toMatchObject({ done: true }) - await expect(hostEnd).resolves.toMatchObject({ done: true }) - expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true) - errors.mockRestore() - fetch.mockRestore() - }) - - it('maps an HTTPS page origin to a secure WebSocket URL', async () => { - ;(globalThis as Win).location = { - hostname: 'harness.example', search: '', origin: 'https://harness.example', - } - ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket - const client = (await mount()).api - const abort = new AbortController() - const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() - const pending = iterator.next() - await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') }) - abort.abort() - await expect(pending).resolves.toMatchObject({ done: true }) - }) - - it('closes a WebSocket immediately when its signal was already aborted', async () => { - ;(globalThis as Win).location = { - hostname: 'localhost', search: '', origin: 'http://localhost:3080', - } - ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket - const client = (await mount()).api - const abort = new AbortController() - abort.abort() - const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ done: true }) - expect(sockets).toHaveLength(1) - expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) it('carries RPC calls without requiring secure-context randomUUID', async () => { @@ -319,6 +302,44 @@ describe('connection client apply', () => { }) }) + it('exposes a worker-local Gateway stream through connection.rpc.open', async () => { + ;(globalThis as Win).location = { hostname: 'preview.example', search: '' } + const openStream = vi.fn>( + (endpoint, payload, signal) => (async function *(): AsyncGenerator { + signal.throwIfAborted() + yield { endpoint, payload } + })(), + ) + ;(globalThis as Win).__DSH_TRANSPORT__ = { + createApiClient: () => new FixtureApiClient(), + fetch: vi.fn(), + openStream, + ownsHost: true, + } + const handle = await mount() + const abort = new AbortController() + const open = handle.rpc.open + if (open === undefined) throw new Error('worker-local stream carrier was not installed') + + const values = [] + for await (const value of open('/api', 'session/follow', { args: { sessionId: 'session-1' } }, abort.signal)) { + values.push(value) + } + expect(values).toEqual([{ + endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } }, + }]) + expect(openStream).toHaveBeenCalledWith( + 'session/follow', + { args: { sessionId: 'session-1' } }, + abort.signal, + ) + expect(handle.isLoopback).toBe(true) + expect(() => open('/rpc', 'session/follow', {}, abort.signal)) + .toThrow('worker-local streams require the /api channel') + expect(() => open('/api/path', 'session/follow', {}, abort.signal)) + .toThrow('invalid RPC target') + }) + it('validates generic RPC transport failures, correlation, and targets', async () => { ;(globalThis as Win).location = { hostname: 'harness.example', search: '', origin: 'https://harness.example', @@ -345,6 +366,51 @@ describe('connection client apply', () => { const fetch = vi.mocked(globalThis.fetch) expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create')) expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') + + const respond = (result: unknown): void => { + globalThis.fetch = async (_input: URL | RequestInfo, init?: RequestInit) => { + if (typeof init?.body !== 'string') throw new TypeError('expected a JSON request body') + const request = JSON.parse(init.body) as { rpcId: string } + return Response.json({ type: 'server-response', rpcId: request.rpcId, result }) + } + } + for (const envelope of [ + null, + { type: 'other', rpcId: 'rpc', result: { ok: true } }, + { type: 'server-response', rpcId: 1, result: { ok: true } }, + ]) { + globalThis.fetch = vi.fn().mockResolvedValue(Response.json(envelope)) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response envelope') + } + + respond(null) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response result') + respond({ ok: 'yes' }) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response result') + respond({ ok: false, error: null }) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response result') + + for (const error of [ + { code: 1, message: 'failed', details: {} }, + { code: 'failed', message: 1, details: {} }, + { code: 'failed', message: 'failed', details: [] }, + ]) { + respond({ ok: false, error }) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response failure') + } + respond({ + ok: false, + error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } }, + }) + await expect(handle.rpc.call('/api', 'goals/create', {})).resolves.toEqual({ + ok: false, + error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } }, + }) } finally { globalThis.fetch = original } diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 7965d627f4..03f57f9273 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -1,32 +1,24 @@ /** - * ConnectionController: stream pumping into sinks, the strict readiness - * handshake (describe + both streams' onOpen, timeout-guarded), generation + * ConnectionController: strict readiness handshake (describe + incremental + * source ready), generation * abort on loss, backoff reconnection, state transitions, and sink-exception * isolation. Real (short) timers — the timeout and backoff are configurable, * so tests run them at millisecond scale. */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '../src/client/api.ts' import type { ConnectionState } from '../src/client/connection.ts' import { ConnectionController } from '../src/client/connection.ts' import { FakeApiClient, deferred, ok } from './fake-api.client.ts' -const SID = 'fk-c1' as SessionId -const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 } - -function subscribedFrame(lastSeq = 0) { - return { type: 'session/subscribed', sessionId: SID, lastSeq } as const -} +const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 } describe('connection lifecycle', () => { - it('announces connected after describe + both streams open, then pumps frames to sinks', async () => { + it('announces connected after describe plus generation readiness', async () => { const api = new FakeApiClient() - const muxSeen: string[] = [] const descriptions: boolean[] = [] let connected = 0 - const controller = new ConnectionController(api, { - onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type), + const controller = new ConnectionController(api, api.generation, { onConnected: (description) => { connected++ descriptions.push(description.canOpenPath) @@ -35,8 +27,6 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - api.pushMux(subscribedFrame()) - await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) }) expect(api.callsOf('host.describe')).toHaveLength(1) expect(descriptions).toEqual([true]) } finally { @@ -44,25 +34,25 @@ describe('connection lifecycle', () => { } }) - it('reconnects with a fresh generation when a stream fails, and stop() ends the loop', async () => { + it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => { const api = new FakeApiClient() let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) api.failStreams(new Error('stream torn')) await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff - expect(api.openMuxCount).toBe(1) // the dead generation's stream is gone, exactly one live + expect(api.openGenerationCount).toBe(1) } finally { controller.stop() warnSpy.mockRestore() } - // stop() aborts the live generation (streams tear down) and no reconnect follows. - await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) }) + // stop() aborts the live generation and no reconnect follows. + await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) await new Promise(resolve => setTimeout(resolve, 40)) - expect(api.openMuxCount).toBe(0) + expect(api.openGenerationCount).toBe(0) }) it('treats describe failure as generation failure and retries', async () => { @@ -75,7 +65,7 @@ describe('connection lifecycle', () => { } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff @@ -106,7 +96,7 @@ describe('connection lifecycle', () => { } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) @@ -117,70 +107,45 @@ describe('connection lifecycle', () => { } }) - it('converges stream/error frames into reconnect instead of dispatching them', async () => { + it('isolates a connected sink exception from the generation', async () => { const api = new FakeApiClient() - const muxSeen: string[] = [] - let connected = 0 - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { - onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type), - onConnected: () => { connected++ }, - }, FAST) - controller.start() - try { - await vi.waitFor(() => { expect(connected).toBe(1) }) - api.pushMux({ type: 'stream/error', error: { code: 'internal', message: 'impl broke', details: {} } }) - await vi.waitFor(() => { expect(connected).toBe(2) }) // treated as loss → reconnect - expect(muxSeen).toEqual([]) // never forwarded to the business sink - } finally { - controller.stop() - warnSpy.mockRestore() - } - }) - - it('isolates sink exceptions from the pump', async () => { - const api = new FakeApiClient() - const seen: string[] = [] let connected = 0 const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { - onMuxEnvelope: (envelope) => { - seen.push(envelope.payload.type) + const controller = new ConnectionController(api, api.generation, { + onConnected: () => { + connected++ throw new Error('business layer bug') }, - onConnected: () => { connected++ }, }, FAST) controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - api.pushMux(subscribedFrame(1)) - api.pushMux(subscribedFrame(2)) - await vi.waitFor(() => { expect(seen).toHaveLength(2) }) // second frame still pumped - expect(connected).toBe(1) // no reconnect triggered by the sink throw + expect(api.openGenerationCount).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error)) } finally { controller.stop() errorSpy.mockRestore() } }) - it('holds onConnected until both streams establish even after describe succeeds', async () => { + it('holds onConnected until the incremental source is ready after describe succeeds', async () => { const api = new FakeApiClient() - api.holdStreamOpen = true // describe resolves immediately; stream establishment is in the case's hand + api.holdGenerationReady = true let connected = 0 - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) await new Promise(resolve => setTimeout(resolve, 30)) expect(connected).toBe(0) // describe alone must not announce - api.releaseStreamOpens() + api.releaseGenerationReady() await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() } }) - it('rejects a generation whose streams end during readiness and retries', async () => { + it('rejects a generation whose source ends during readiness and retries', async () => { const api = new FakeApiClient() const firstDescribe = deferred>>() let describeCalls = 0 @@ -193,13 +158,13 @@ describe('connection lifecycle', () => { const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) }) + await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) }) api.endStreams() firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) @@ -212,16 +177,54 @@ describe('connection lifecycle', () => { } }) - it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => { + it.each([ + { label: 'ends normally', fail: () => Promise.resolve() }, + { + label: 'rejects with a non-Error reason', + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario. + fail: () => Promise.reject('fixture offline'), + }, + ])('retries when the generation source $label before reporting ready', async ({ fail }) => { const api = new FakeApiClient() - api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires + let sourceCalls = 0 let connected = 0 - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, { ...FAST, streamOpenTimeoutMs: 20 }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, (signal, ready) => { + sourceCalls++ + if (sourceCalls === 1) return fail() + ready() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + }, { onConnected: () => { connected++ } }, FAST) controller.start() try { - await vi.waitFor(() => { expect(connected).toBe(1) }) // handshake resolved by the guard, not wedged + await vi.waitFor(() => { expect(sourceCalls).toBe(2) }) + await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() + warnSpy.mockRestore() + } + }) + + it('rejects and retries a generation whose source never reports ready', async () => { + const api = new FakeApiClient() + api.suppressGenerationReady = true + let connected = 0 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController( + api, + api.generation, + { onConnected: () => { connected++ } }, + { ...FAST, generationReadyTimeoutMs: 20 }, + ) + controller.start() + try { + await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) }) + expect(connected).toBe(0) + } finally { + controller.stop() + warnSpy.mockRestore() } }) @@ -230,7 +233,7 @@ describe('connection lifecycle', () => { const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) @@ -251,7 +254,7 @@ describe('connection lifecycle', () => { const api = new FakeApiClient() const states: ConnectionState[] = [] let connected = 0 - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: (state) => { states.push(state) @@ -261,7 +264,7 @@ describe('connection lifecycle', () => { controller.start() await vi.waitFor(() => { expect(states).toEqual(['connected']) }) - await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) }) + await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) expect(connected).toBe(0) }) @@ -276,7 +279,7 @@ describe('connection lifecycle', () => { const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) @@ -294,11 +297,10 @@ describe('connection lifecycle', () => { it('runs with no sinks at all (every callback slot optional)', async () => { const api = new FakeApiClient() - const controller = new ConnectionController(api, {}, FAST) + const controller = new ConnectionController(api, api.generation, {}, FAST) controller.start() try { await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) - api.pushMux(subscribedFrame()) // pumped with sink undefined: dropped silently await new Promise(resolve => setTimeout(resolve, 20)) } finally { controller.stop() @@ -308,12 +310,12 @@ describe('connection lifecycle', () => { it('start() is idempotent (one loop, one stream set)', async () => { const api = new FakeApiClient() let connected = 0 - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.openMuxCount).toBe(1) + expect(api.openGenerationCount).toBe(1) expect(api.callsOf('host.describe')).toHaveLength(1) } finally { controller.stop() diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 7c9dc6accb..f0c39b3657 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -1,10 +1,8 @@ // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and -// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. -import type { - HostFrame, IApiClient, ModelSelection, MuxFrame, - RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, -} from '../src/client/api.ts' +// deferred-controlled timing). The generation source is a hand pump. +import type { IApiClient, RpcResponse, SkillEntry } from '../src/client/api.ts' +import type { ConnectionGenerationSource } from '../src/client/connection.ts' import { RpcId } from '../src/client/api.ts' export interface Deferred { @@ -31,10 +29,10 @@ export function ok(value: T): RpcResponse { } -type StreamItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } | { kind: 'fail'; error: unknown } +type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown } -interface StreamConn { - feed(item: StreamItem): void +interface StreamConn { + feed(item: StreamItem): void } export class FakeApiClient implements IApiClient { @@ -42,34 +40,6 @@ export class FakeApiClient implements IApiClient { readonly calls: { method: string; payload: unknown }[] = [] // Programmable slots (defaults answer OK-empty); reassign per case. - onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) - onSearch: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ items: [], hasMore: false })) - onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) - onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) - onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) - onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = - () => Promise.resolve(ok({ - events: [], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, - })) - - onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ - current: { provider: 'deepseek-official', model: 'deepseek-chat' }, - routable: true, - groups: [], - failures: [], - })) - onSelectModel: (payload: ModelSelection & { sessionId: SessionId }) - => Promise> = - payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) - onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onAttachment: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) - onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise Promise> = () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly muxConns: StreamConn[] = [] - private readonly hostConns: StreamConn[] = [] - lastSearchSignal: AbortSignal | undefined - - // Parameter annotations below are local structural types on purpose: the CI - // lint lane runs without built artifacts, where IApiClient's wire types - // (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument. - readonly sessions: IApiClient['sessions'] = { - list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)), - search: (payload: unknown, signal?: AbortSignal) => { - this.lastSearchSignal = signal - return this.record('session.search', payload, this.onSearch(payload)) - }, - create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), - history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => - this.record('session.history', payload, this.onHistory(payload)), - models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)), - selectModel: (payload: ModelSelection & { sessionId: SessionId }) => - this.record('session.selectModel', payload, this.onSelectModel(payload)), - rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), - fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), - prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), - attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), - updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), - cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), - } + private readonly generationConns: StreamConn[] = [] readonly subagents: IApiClient['subagents'] = { list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({ entries: [], parentAvailable: true, }))), - history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({ - events: [], - hasMore: false, - }))), prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({ messageId: 'fake-message' as never, }))), @@ -149,27 +90,6 @@ export class FakeApiClient implements IApiClient { openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } - readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))), - create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({ - workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, - created: true, - }))), - rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ - workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, - }))), - delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))), - insertBefore: (payload: unknown) => this.record('workspace.insertBefore', payload, Promise.resolve(ok({ - workspaceIds: [(payload as { workspaceId: WorkspaceId }).workspaceId], - }))), - insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ - workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, - }))), - archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({ - archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId], - }))), - } - // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program catalogs and skill lists without casts. onSkillList: (payload: unknown) => Promise> @@ -225,51 +145,33 @@ export class FakeApiClient implements IApiClient { discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), } - /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ - suppressStreamOpen = false + /** When true, the source never reports ready. */ + suppressGenerationReady = false - /** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them. - * Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */ - holdStreamOpen = false + /** When true, ready callbacks remain parked until the test releases them. */ + holdGenerationReady = false private heldOpens: (() => void)[] = [] - releaseStreamOpens(): void { + releaseGenerationReady(): void { const held = this.heldOpens this.heldOpens = [] for (const fire of held) fire() } - readonly events: IApiClient['events'] = { - mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => - this.openStream(this.muxConns, signal, onOpen), - host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => - this.openStream(this.hostConns, signal, onOpen), - } - - respond(): Promise<{ accepted: false; reason: 'not-pending' }> { - return Promise.resolve({ accepted: false, reason: 'not-pending' }) - } - - /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */ - pushMux(frame: MuxFrame, rpcId?: string): void { - for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) - } - - pushHost(frame: HostFrame, rpcId?: string): void { - for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) - } + readonly generation: ConnectionGenerationSource = (signal, ready) => + this.openGeneration(signal, ready) /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ endStreams(): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' }) + for (const conn of [...this.generationConns]) conn.feed({ kind: 'end' }) } failStreams(error: unknown): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error }) + for (const conn of [...this.generationConns]) conn.feed({ kind: 'fail', error }) } - get openMuxCount(): number { - return this.muxConns.length + get openGenerationCount(): number { + return this.generationConns.length } callsOf(method: string): unknown[] { @@ -281,25 +183,24 @@ export class FakeApiClient implements IApiClient { return response } - private async *openStream(registry: StreamConn[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator> { - const inbox: StreamItem[] = [] + private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise { + const inbox: StreamItem[] = [] let wake: (() => void) | null = null - const conn: StreamConn = { + const conn: StreamConn = { feed: (item) => { inbox.push(item) wake?.() }, } - registry.push(conn) - if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen) - else if (!this.suppressStreamOpen) onOpen?.() + this.generationConns.push(conn) + if (this.holdGenerationReady) this.heldOpens.push(onOpen) + else if (!this.suppressGenerationReady) onOpen() try { while (!signal.aborted) { while (inbox.length > 0) { - const item = inbox.shift() as StreamItem + const item = inbox.shift() as StreamItem if (item.kind === 'end') return if (item.kind === 'fail') throw item.error - yield item.envelope } await new Promise((resolve) => { wake = resolve @@ -308,7 +209,7 @@ export class FakeApiClient implements IApiClient { wake = null } } finally { - registry.splice(registry.indexOf(conn), 1) + this.generationConns.splice(this.generationConns.indexOf(conn), 1) } } } diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index 62118062b5..163fb414a6 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -45,15 +45,18 @@ describe('createFixtureApi commands/skills', () => { expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) - it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => { - const { api, rpc } = createFixtureFaces() + it('executes a known command line: pure admission plus a followed lifecycle pair', async () => { + const { rpc } = createFixtureFaces() const frames: unknown[] = [] const abort = new AbortController() - const stream = api.events.mux(req({}), abort.signal) + const stream = rpc.open?.('/api', 'session/follow', { + args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } }, + }, abort.signal) + if (stream === undefined) throw new Error('fixture session follow stream is unavailable') const pump = (async () => { for await (const frame of stream) { - frames.push(frame.payload) - if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() + frames.push(frame) + if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort() } })() const execution = await callRemote<{ commandId: string } | undefined>( @@ -61,7 +64,7 @@ describe('createFixtureApi commands/skills', () => { expect(execution?.commandId).toBeTruthy() await pump const events = frames - .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') + .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') .map(f => f.event) expect(events).toMatchObject([ { type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } }, @@ -83,14 +86,17 @@ describe('createFixtureApi commands/skills', () => { }) it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => { - const { api, rpc } = createFixtureFaces() + const { rpc } = createFixtureFaces() const frames: unknown[] = [] const abort = new AbortController() - const stream = api.events.mux(req({}), abort.signal) + const stream = rpc.open?.('/api', 'session/follow', { + args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } }, + }, abort.signal) + if (stream === undefined) throw new Error('fixture session follow stream is unavailable') const pump = (async () => { for await (const frame of stream) { - frames.push(frame.payload) - if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() + frames.push(frame) + if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort() } })() const png = { mediaType: 'image/png', data: 'AA==' } @@ -100,7 +106,7 @@ describe('createFixtureApi commands/skills', () => { expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' }) await pump const events = frames - .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') + .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') .map(f => f.event) expect(events).toMatchObject([ { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } }, diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 0842eb8ed3..58c347fec3 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -1,13 +1,423 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionId, WorkspaceId } from '../src/client/api.ts' +import type { + ModelProviderGroup, + ModelSelection, + RpcMessage, + RpcRequest, + RpcResponse, + RpcResult, + SessionEvent, + SessionId, +} from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' -import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts' -import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts' +import { + FixtureApiClient, + createFixtureFaces, + type FixtureOptions, +} from '../src/client/fixture.ts' +import type { + ClientConnectionRpc, +} from '../src/rpc.ts' const sid = (id: string): SessionId => id as SessionId +type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload }) let reqCount = 0 +interface FixtureSessionSummary { + sessionId: SessionId + updatedAt: number + running: boolean + blank: boolean + parentSessionId?: SessionId + origin?: 'subagent' + cwd?: string + agentPreset?: string +} + +interface FixtureHistoryEntry { + readonly event: SessionEvent + readonly view?: unknown +} + +interface FixturePage { + readonly events: readonly FixtureHistoryEntry[] + readonly hasMore: boolean + readonly projections?: { + readonly asOfSeq: number + readonly values: Readonly> + } +} + +type FixtureFollowFrame = + | { readonly type: 'opened'; readonly cursor: number } + | ({ readonly type: 'event' } & FixtureHistoryEntry) + +type FixtureControlFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly queues: Readonly> + readonly jobs: Readonly> + readonly approvals: readonly unknown[] + readonly questions: readonly unknown[] + readonly projections: Readonly> + }>> + } + } + | { + readonly type: 'projection' + readonly sessionId: SessionId + readonly key: string + readonly value: unknown + readonly seq: number + } + +interface FixtureSessionRequests { + list: { readonly cursor?: string } + search: { readonly query: string } + create: { + readonly workspaceId?: WorkspaceId + readonly cwd?: string + readonly sessionId?: SessionId + readonly agentPreset?: string + } + history: { + readonly sessionId: SessionId + readonly beforeSeq?: number + readonly maxMessages?: number + } + models: { readonly sessionId: SessionId } + selectModel: { + readonly sessionId: SessionId + readonly provider: string + readonly model: string + readonly reasoningEffort?: string + } + prompt: { + readonly sessionId: SessionId + readonly mode: 'queue' | 'steer' + readonly content: readonly ({ readonly type: 'text'; readonly text: string } | { + readonly type: 'image' + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + readonly data: string + readonly name?: string + })[] + } + cancel: { readonly sessionId: SessionId } + rename: { readonly sessionId: SessionId; readonly title: string } +} + +interface FixtureSessionValues { + list: { readonly items: FixtureSessionSummary[] } + search: { readonly items: readonly { readonly sessionId: SessionId; readonly snippet: string }[]; readonly hasMore: boolean } + create: { readonly sessionId: SessionId } + history: FixturePage + models: { + readonly current: ModelSelection + readonly routable: boolean + readonly groups: readonly ModelProviderGroup[] + readonly failures: readonly unknown[] + } + selectModel: { readonly selected: ModelSelection } + prompt: { readonly accepted: true } + cancel: Record + rename: { readonly title: string; readonly seq: number } +} + +type FixtureSessionApi = { + [K in keyof FixtureSessionRequests]: ( + request: RpcRequest, + signal?: AbortSignal, + ) => Promise> +} + +type FixtureSessionClient = { + [K in keyof FixtureSessionRequests]: ( + request: FixtureSessionRequests[K], + signal?: AbortSignal, + ) => Promise> +} + +interface FixtureSessionRemote { + follow(sessionId: SessionId, signal: AbortSignal, afterSeq?: number): AsyncIterable + control(signal: AbortSignal): AsyncIterable +} + +interface FixtureWorkspaceView { + readonly workspaceId: WorkspaceId + readonly path: string + readonly title: string + readonly sessionIds: readonly SessionId[] + readonly createdAt: string + readonly updatedAt: string +} + +interface FixtureWorkspaceRequests { + create: { readonly path: string } + rename: { readonly workspaceId: WorkspaceId; readonly title: string } + delete: { readonly workspaceId: WorkspaceId } + insertBefore: { readonly workspaceId: WorkspaceId; readonly beforeWorkspaceId?: WorkspaceId } + insertSessionBefore: { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId + } + archiveSession: { readonly sessionId: SessionId } +} + +interface FixtureWorkspaceValues { + create: { readonly workspace: FixtureWorkspaceView; readonly created: boolean } + rename: { readonly workspace: FixtureWorkspaceView } + delete: { readonly deleted: true } + insertBefore: { readonly workspaceIds: readonly WorkspaceId[] } + insertSessionBefore: { readonly workspace: FixtureWorkspaceView } + archiveSession: { readonly archivedSessionIds: readonly SessionId[] } +} + +type FixtureWorkspaceApi = { + [K in keyof FixtureWorkspaceRequests]: ( + request: RpcRequest, + signal?: AbortSignal, + ) => Promise> +} + +type FixtureWorkspaceClient = { + [K in keyof FixtureWorkspaceRequests]: ( + request: FixtureWorkspaceRequests[K], + signal?: AbortSignal, + ) => Promise> +} + +type FixtureWorkspaceFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly items: readonly FixtureWorkspaceView[] + readonly archivedSessionIds: readonly SessionId[] + } + } + | { readonly type: 'upsert'; readonly workspace: FixtureWorkspaceView } + | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } + | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + +interface FixtureWorkspaceRemote { + follow(signal: AbortSignal): AsyncIterable +} + +interface FixtureRemoteEventNotificationFrame { + readonly type: 'emit' + readonly event: string + readonly args: readonly unknown[] +} + +interface FixtureRemoteEventRequestFrame { + readonly type: 'waterfall' + readonly event: string + readonly eventId: string + readonly agentId: SessionId + readonly request: Readonly> +} + +interface FixtureRemoteEventCancellationFrame { + readonly type: 'cancel' + readonly eventId: string +} + +type FixtureRemoteEventFrame = + | FixtureRemoteEventNotificationFrame + | FixtureRemoteEventRequestFrame + | FixtureRemoteEventCancellationFrame + +interface FixtureRemoteEventResult { + readonly clientId: string + readonly eventId: string + readonly outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { + readonly kind: 'rejected' + readonly error: { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown + } + } +} + +interface FixtureRemoteEventStream extends AsyncIterable { + readonly clientId: Promise +} + +type FixtureTestApi = ReturnType['api'] & { + readonly sessions: FixtureSessionApi + readonly sessionRemote: FixtureSessionRemote + readonly workspace: FixtureWorkspaceApi + readonly workspaceRemote: FixtureWorkspaceRemote + readonly remoteEvents: (signal: AbortSignal) => FixtureRemoteEventStream + readonly answerRemoteEvent: (result: FixtureRemoteEventResult) => Promise +} + +/** Keep existing fixture assertions compact while driving only the new Session Remote endpoints. */ +function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { + const { api, rpc } = createFixtureFaces(options) + return Object.assign(api, { + sessions: createSessionApi(rpc), + sessionRemote: createSessionRemote(rpc), + workspace: createWorkspaceApi(rpc), + workspaceRemote: createWorkspaceRemote(rpc), + remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal), + answerRemoteEvent: (result: FixtureRemoteEventResult) => + rpc.call('/api', '$events/result', { args: result }), + }) +} + +function openFixtureRemoteEvents( + rpc: ClientConnectionRpc, + signal: AbortSignal, +): FixtureRemoteEventStream { + const ready = Promise.withResolvers() + const source = (async function* (): AsyncGenerator { + const stream = rpc.open?.('/api', '$events', { args: {} }, signal) + if (stream === undefined) throw new Error('fixture forwarded-event stream is unavailable') + let opened = false + for await (const value of stream) { + if (!opened) { + expect(value).toMatchObject({ type: 'ready' }) + const clientId: unknown = Reflect.get(value as object, 'clientId') + if (typeof clientId !== 'string') throw new Error('fixture forwarded-event stream omitted its Client id') + ready.resolve(clientId) + opened = true + continue + } + yield value as FixtureRemoteEventFrame + } + })() + return Object.assign(source, { clientId: ready.promise }) +} + +function createSessionApi(rpc: ClientConnectionRpc): FixtureSessionApi { + const call = async ( + endpoint: K, + request: RpcRequest, + signal?: AbortSignal, + ): Promise> => { + const page = endpoint === 'history' + ? request.payload as FixtureSessionRequests['history'] + : undefined + const args = endpoint === 'list' + ? { _request: request.payload } + : endpoint === 'history' + ? { + request: { + address: { kind: 'session', sessionId: page?.sessionId }, + ...page?.beforeSeq === undefined ? {} : { beforeSeq: page.beforeSeq }, + ...page?.maxMessages === undefined ? {} : { maxMessages: page.maxMessages }, + }, + } + : { request: request.payload } + const remoteEndpoint = endpoint === 'history' ? 'page' : endpoint + const result = await rpc.call('/api', `session/${remoteEndpoint}`, { args }, signal) + return { + rpcId: request.rpcId, + result: result as unknown as RpcResult, + } + } + return { + list: (request, signal) => call('list', request, signal), + search: (request, signal) => call('search', request, signal), + create: (request, signal) => call('create', request, signal), + history: (request, signal) => call('history', request, signal), + models: (request, signal) => call('models', request, signal), + selectModel: (request, signal) => call('selectModel', request, signal), + prompt: (request, signal) => call('prompt', request, signal), + cancel: (request, signal) => call('cancel', request, signal), + rename: (request, signal) => call('rename', request, signal), + } +} + +function createSessionClient(rpc: ClientConnectionRpc): FixtureSessionClient { + const api = createSessionApi(rpc) + return { + list: (request, signal) => api.list(req(request), signal), + search: (request, signal) => api.search(req(request), signal), + create: (request, signal) => api.create(req(request), signal), + history: (request, signal) => api.history(req(request), signal), + models: (request, signal) => api.models(req(request), signal), + selectModel: (request, signal) => api.selectModel(req(request), signal), + prompt: (request, signal) => api.prompt(req(request), signal), + cancel: (request, signal) => api.cancel(req(request), signal), + rename: (request, signal) => api.rename(req(request), signal), + } +} + +function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote { + const open = (endpoint: string, args: object, signal: AbortSignal): AsyncIterable => { + const stream = rpc.open?.('/api', endpoint, { args }, signal) + if (stream === undefined) throw new Error(`fixture ${endpoint} stream is unavailable`) + return stream as AsyncIterable + } + return { + follow: (sessionId, signal, afterSeq) => open('session/follow', { + request: { + address: { kind: 'session', sessionId }, + ...afterSeq === undefined ? {} : { afterSeq }, + }, + }, signal), + control: signal => open('session/control', {}, signal), + } +} + +function createWorkspaceApi(rpc: ClientConnectionRpc): FixtureWorkspaceApi { + const call = async ( + endpoint: K, + request: RpcRequest, + signal?: AbortSignal, + ): Promise> => { + const result = await rpc.call('/api', `workspace/${endpoint}`, { + args: { request: request.payload }, + }, signal) + return { + rpcId: request.rpcId, + result: result as unknown as RpcResult, + } + } + return { + create: (request, signal) => call('create', request, signal), + rename: (request, signal) => call('rename', request, signal), + delete: (request, signal) => call('delete', request, signal), + insertBefore: (request, signal) => call('insertBefore', request, signal), + insertSessionBefore: (request, signal) => call('insertSessionBefore', request, signal), + archiveSession: (request, signal) => call('archiveSession', request, signal), + } +} + +function createWorkspaceClient(rpc: ClientConnectionRpc): FixtureWorkspaceClient { + const api = createWorkspaceApi(rpc) + return { + create: (request, signal) => api.create(req(request), signal), + rename: (request, signal) => api.rename(req(request), signal), + delete: (request, signal) => api.delete(req(request), signal), + insertBefore: (request, signal) => api.insertBefore(req(request), signal), + insertSessionBefore: (request, signal) => api.insertSessionBefore(req(request), signal), + archiveSession: (request, signal) => api.archiveSession(req(request), signal), + } +} + +function createWorkspaceRemote(rpc: ClientConnectionRpc): FixtureWorkspaceRemote { + return { + follow(signal) { + const stream = rpc.open?.('/api', 'workspace/follow', { args: {} }, signal) + if (stream === undefined) throw new Error('fixture workspace/follow stream is unavailable') + return stream as AsyncIterable + }, + } +} + interface TimingHooks { setHistoryDelay(ms: number): void failNextHistory(): void @@ -32,11 +442,11 @@ interface TimingHooks { } const timing = (): TimingHooks => (globalThis as Record).__fxTiming as TimingHooks -/** Collect stream frames until the predicate or a soft cap; abort ends the stream. */ -async function collect(stream: AsyncIterable>, abort: AbortController, done: (frames: F[]) => boolean): Promise { +/** Collect value-stream frames until the predicate or a soft cap; abort ends the stream. */ +async function collectValues(stream: AsyncIterable, abort: AbortController, done: (frames: F[]) => boolean): Promise { const frames: F[] = [] - for await (const envelope of stream) { - frames.push(envelope.payload) + for await (const frame of stream) { + frames.push(frame) if (done(frames) || frames.length > 500) { abort.abort() break @@ -45,6 +455,70 @@ async function collect(stream: AsyncIterable>, abort: AbortCont return frames } +async function readControlBaseline(remote: FixtureSessionRemote): Promise> { + const abort = new AbortController() + for await (const frame of remote.control(abort.signal)) { + if (frame.type !== 'baseline') continue + abort.abort() + return frame + } + throw new Error('fixture control baseline missing') +} + +function isRemoteEventRequest(frame: FixtureRemoteEventFrame): frame is FixtureRemoteEventRequestFrame { + return frame.type === 'waterfall' +} + +function isRemoteEventCancellation(frame: FixtureRemoteEventFrame): frame is FixtureRemoteEventCancellationFrame { + return frame.type === 'cancel' +} + +async function readResidentRemoteEvents( + api: FixtureTestApi, + count: number, +): Promise { + const abort = new AbortController() + const frames = await collectValues( + api.remoteEvents(abort.signal), + abort, + seen => seen.filter(isRemoteEventRequest).length >= count, + ) + return frames.filter(isRemoteEventRequest) +} + +async function nextRemoteEvent( + iterator: AsyncIterator, + predicate: (frame: FixtureRemoteEventFrame) => boolean, +): Promise { + for (;;) { + const item = await iterator.next() + if (item.done) throw new Error('fixture Remote Event stream ended before the expected frame') + if (predicate(item.value)) return item.value + } +} + +async function readOpeningCursor(remote: FixtureSessionRemote, sessionId: SessionId): Promise { + const abort = new AbortController() + for await (const frame of remote.follow(sessionId, abort.signal)) { + if (frame.type !== 'opened') continue + abort.abort() + return frame.cursor + } + throw new Error('fixture follow opening cursor missing') +} + +async function readWorkspaceBaseline( + remote: FixtureWorkspaceRemote, +): Promise['value']> { + const abort = new AbortController() + for await (const frame of remote.follow(abort.signal)) { + if (frame.type !== 'baseline') continue + abort.abort() + return frame.value + } + throw new Error('fixture Workspace baseline missing') +} + describe('createFixtureApi', () => { it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => { const api = createFixtureApi() @@ -254,14 +728,16 @@ describe('createFixtureApi', () => { expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2) }) - it('create adds a session and pushes host/session-added to open host streams', async () => { + it('create adds a session and announces it through the Host Remote event stream', async () => { const api = createFixtureApi() const abort = new AbortController() - const seen: HostFrame[] = [] + const seen: FixtureRemoteEventNotificationFrame[] = [] const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) { - seen.push(envelope.payload) - if (seen.length >= 1) abort.abort() + for await (const frame of api.remoteEvents(abort.signal)) { + if (frame.type !== 'emit' || frame.event !== 'api-session/added') continue + seen.push(frame) + abort.abort() + break } })() await new Promise(resolve => setTimeout(resolve, 10)) // let the stream register @@ -272,9 +748,9 @@ describe('createFixtureApi', () => { const createdId = created.result.value.sessionId expect(seen).toHaveLength(1) const added = seen[0] - if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') - expect(added).toEqual({ - type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture', + expect(added).toMatchObject({ + event: 'api-session/added', + args: [{ sessionId: createdId, blank: true, cwd: '/tmp/fixture' }], }) const list = await api.sessions.list(req({})) if (!list.result.ok) throw new Error('list failed') @@ -286,16 +762,16 @@ describe('createFixtureApi', () => { const created = await api.sessions.create(req({})) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId - const abort = new AbortController() - const frames: MuxFrame[] = [] - const consuming = (async () => { - for await (const envelope of api.events.mux(req({}), abort.signal)) { - frames.push(envelope.payload) - const last = envelope.payload - if (last.type === 'session/event' && last.event.type === 'turn/end') { - abort.abort() - } - } + const followAbort = new AbortController() + const controlAbort = new AbortController() + const controlFrames: FixtureControlFrame[] = [] + const followPromise = collectValues( + api.sessionRemote.follow(id, followAbort.signal), + followAbort, + frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end'), + ) + const controlPromise = (async () => { + for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlFrames.push(frame) })() await new Promise(resolve => setTimeout(resolve, 10)) // Unknown session → session-not-found with the id echoed in details. @@ -306,8 +782,8 @@ describe('createFixtureApi', () => { expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks await api.sessions.cancel(req({ sessionId: id })) - await consuming - const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) + const frames = await followPromise + const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) expect(types).toContain('turn/start') expect(types).toContain('user/message') expect(types).toContain('assistant/chunk') @@ -316,20 +792,25 @@ describe('createFixtureApi', () => { // Capacity is durable log state, not a transient frame: the prompt path // records request/context and the projection carries it to the client. expect(types).toContain('request/context') - expect(frames.some(frame => - frame.type === 'session/projection' + await vi.waitFor(() => { + expect(controlFrames.some(frame => + frame.type === 'projection' + && frame.key === 'contextBreakdown' + && (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true) + }) + expect(controlFrames.some(frame => + frame.type === 'projection' && frame.key === 'tokenUsage' && (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true) - expect(frames.some(frame => - frame.type === 'session/projection' + expect(controlFrames.some(frame => + frame.type === 'projection' && frame.key === 'contextPressure' && (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true) - expect(frames.some(frame => - frame.type === 'session/projection' - && frame.key === 'contextBreakdown' - && (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true) - const finalize = frames.find((f): f is Extract => f.type === 'session/event' && f.event.type === 'assistant/message') + const finalize = frames.find(frame => frame.type === 'event' && frame.event.type === 'assistant/message') + if (finalize?.type !== 'event') throw new Error('assistant final event missing') expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)') + controlAbort.abort() + await controlPromise // Idle cancel: no replay in flight, must not explode; running flips false. const idleCancel = await api.sessions.cancel(req({ sessionId: id })) expect(idleCancel.result).toMatchObject({ ok: true }) @@ -341,99 +822,93 @@ describe('createFixtureApi', () => { if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId const abort = new AbortController() - const framesPromise = collect(api.events.mux(req({}), abort.signal), abort, - frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end')) + const framesPromise = collectValues(api.sessionRemote.follow(id, abort.signal), abort, + frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end')) await new Promise(resolve => setTimeout(resolve, 10)) await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '短' }] })) await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] })) const frames = await framesPromise - const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) + const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) expect(JSON.stringify(frames)).toContain('插话') expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn }) - it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => { + it('control replays projections while resident Remote Events retain ids across reconnects', async () => { const api = createFixtureApi() - const openOnce = async (): Promise[]> => { - const abort = new AbortController() - const envelopes: RpcRequest[] = [] - for await (const envelope of api.events.mux(req({}), abort.signal)) { - envelopes.push(envelope) - if (envelopes.length >= 13) abort.abort() - } - return envelopes - } - const first = await openOnce() - const second = await openOnce() - expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) - expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow subscribed (domain units + token usage). - expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) - expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) - expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' }) - expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) - expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) - expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' }) - expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' }) - expect(first[8]?.payload).toMatchObject({ - type: 'session/projection', sessionId: 'fx-alpha', key: 'contextBreakdown', - value: { systemTokens: 0, toolsTokens: 0 }, + const first = await readControlBaseline(api.sessionRemote) + const second = await readControlBaseline(api.sessionRemote) + expect(first.value.approvals).toEqual([]) + expect(first.value.questions).toEqual([]) + const alpha = first.value.projections['fx-alpha'] + expect(alpha?.asOfSeq).toBeGreaterThan(0) + expect(alpha?.values).toMatchObject({ + title: 'Fixture 历史会话', + plan: { active: false, pending: false }, + goal: null, + imageLimits: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 }, }) - expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0) - expect(first[9]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'sessionStats' }) - expect((first[9]?.payload as { value: { turns: number; steps: number } }).value.steps).toBeGreaterThan(0) - expect(first[10]?.payload).toMatchObject({ - type: 'session/projection', sessionId: 'fx-alpha', key: 'imageLimits', - value: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 }, + expect((alpha?.values['contextBreakdown'] as { messageTokens: number }).messageTokens).toBeGreaterThan(0) + expect((alpha?.values['sessionStats'] as { steps: number }).steps).toBeGreaterThan(0) + expect(second.value.projections['fx-alpha']).toEqual(alpha) + + const firstEvents = await readResidentRemoteEvents(api, 2) + const secondEvents = await readResidentRemoteEvents(api, 2) + const firstApproval = firstEvents.find(frame => frame.event === 'approval/request') + const firstQuestion = firstEvents.find(frame => frame.event === 'user-questions/request') + const secondApproval = secondEvents.find(frame => frame.event === 'approval/request') + const secondQuestion = secondEvents.find(frame => frame.event === 'user-questions/request') + expect(firstApproval).toMatchObject({ + type: 'waterfall', + request: { toolName: 'dangerous_tool' }, + agentId: 'fx-alpha', }) - expect(first[11]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[11]?.rpcId).toBe(first[11]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[12]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[12]?.rpcId).toBe(first[12]?.rpcId) + expect(firstQuestion).toMatchObject({ + type: 'waterfall', + agentId: 'fx-alpha', + }) + expect(Array.isArray(firstQuestion?.request.questions)).toBe(true) + expect(secondApproval?.eventId).toBe(firstApproval?.eventId) + expect(secondQuestion?.eventId).toBe(firstQuestion?.eventId) + expect(await readOpeningCursor(api.sessionRemote, sid('fx-alpha'))).toBeGreaterThan(0) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { const api = createFixtureApi() - const abort = new AbortController() - const framesPromise = collect(api.events.mux(req({}), abort.signal), abort, - frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end')) - await new Promise(resolve => setTimeout(resolve, 10)) const created = await api.sessions.create(req({})) if (!created.result.ok) throw new Error('create failed') + const abort = new AbortController() + const framesPromise = collectValues( + api.sessionRemote.follow(created.result.value.sessionId, abort.signal), + abort, + frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end'), + ) + await new Promise(resolve => setTimeout(resolve, 10)) // steer while idle + a non-text content block (covers the '' arm of the text join). await api.sessions.prompt(req({ sessionId: created.result.value.sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never], })) const frames = await framesPromise - const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) + const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not an in-turn insert }) - it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => { + it('gamma interval flip emits a Remote status event and its empty follow source opens at -1', async () => { vi.useFakeTimers() try { const api = createFixtureApi() const abort = new AbortController() - const hostSeen: HostFrame[] = [] + const hostSeen: FixtureRemoteEventFrame[] = [] const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) hostSeen.push(envelope.payload) + for await (const frame of api.remoteEvents(abort.signal)) hostSeen.push(frame) })() await vi.advanceTimersByTimeAsync(5001) // interval fires: fx-gamma flips running=true (no log exists) - expect(hostSeen).toContainEqual({ type: 'host/session-status', sessionId: sid('fx-gamma'), running: true }) - // A mux stream opened now sees gamma in the baseline with lastSeq = -1 (empty log arm). - const mabort = new AbortController() - const baseline: MuxFrame[] = [] - const muxConsuming = (async () => { - for await (const envelope of api.events.mux(req({}), mabort.signal)) { - baseline.push(envelope.payload) - if (baseline.length >= 3) mabort.abort() - } - })() - await vi.advanceTimersByTimeAsync(10) - mabort.abort() - await muxConsuming - expect(baseline).toContainEqual({ type: 'session/subscribed', sessionId: sid('fx-gamma'), lastSeq: -1 }) + expect(hostSeen).toContainEqual({ + type: 'emit', + event: 'api-session/status', + args: [sid('fx-gamma'), true], + }) + expect(await readOpeningCursor(api.sessionRemote, sid('fx-gamma'))).toBe(-1) abort.abort() await vi.advanceTimersByTimeAsync(10) await consuming @@ -442,76 +917,97 @@ describe('createFixtureApi', () => { } }) - it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => { + it('answers a resident question through its Remote Event id and stops replaying it', async () => { const api = createFixtureApi() - expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' }) const abort = new AbortController() - let question: RpcRequest | undefined - for await (const envelope of api.events.mux(req({}), abort.signal)) { - if (envelope.payload.type !== 'question/requested') continue - question = envelope - abort.abort() - } - if (question === undefined) throw new Error('fixture question missing') - const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } } - expect(await api.respond(response)).toEqual({ accepted: true }) - expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' }) + const stream = api.remoteEvents(abort.signal) + const iterator = stream[Symbol.asyncIterator]() + const question = await nextRemoteEvent( + iterator, + frame => isRemoteEventRequest(frame) && frame.event === 'user-questions/request', + ) + if (!isRemoteEventRequest(question)) throw new Error('fixture question Remote Event missing') + const clientId = await stream.clientId + await expect(api.answerRemoteEvent({ + clientId, + eventId: 'unrelated', + outcome: { kind: 'result', value: {} }, + })).resolves.toEqual({ ok: true, value: undefined }) + await expect(api.answerRemoteEvent({ + clientId, + eventId: question.eventId, + outcome: { kind: 'result', value: { answers: {} } }, + })).resolves.toEqual({ ok: true, value: undefined }) + const cancelled = await nextRemoteEvent( + iterator, + frame => isRemoteEventCancellation(frame) && frame.eventId === question.eventId, + ) + expect(cancelled).toEqual({ type: 'cancel', eventId: question.eventId }) + abort.abort() + await iterator.return?.() - const replayAbort = new AbortController() - const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2) - expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true) + await expect(api.answerRemoteEvent({ + clientId, + eventId: question.eventId, + outcome: { kind: 'result', value: { answers: {} } }, + })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + const remaining = await readResidentRemoteEvents(api, 1) + expect(remaining.map(frame => frame.event)).toEqual(['approval/request']) const cancelledApi = createFixtureApi() const cancelAbort = new AbortController() - let cancelQuestion: RpcRequest | undefined - for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) { - if (envelope.payload.type !== 'question/requested') continue - cancelQuestion = envelope - cancelAbort.abort() - } - if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing') - expect(await cancelledApi.respond({ - type: 'client-response', rpcId: cancelQuestion.rpcId, - result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } }, - })).toEqual({ accepted: true }) + const cancelStream = cancelledApi.remoteEvents(cancelAbort.signal) + const cancelIterator = cancelStream[Symbol.asyncIterator]() + const cancelQuestion = await nextRemoteEvent( + cancelIterator, + frame => isRemoteEventRequest(frame) && frame.event === 'user-questions/request', + ) + if (!isRemoteEventRequest(cancelQuestion)) throw new Error('fixture cancellation question missing') + await expect(cancelledApi.answerRemoteEvent({ + clientId: await cancelStream.clientId, + eventId: cancelQuestion.eventId, + outcome: { + kind: 'rejected', + error: { name: 'UserQuestionError', message: 'skip', code: 'ASK_CANCELLED' }, + }, + })).resolves.toEqual({ ok: true, value: undefined }) + cancelAbort.abort() + await cancelIterator.return?.() + const afterCancellation = await readResidentRemoteEvents(cancelledApi, 1) + expect(afterCancellation.map(frame => frame.event)).toEqual(['approval/request']) }) - it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => { + it('answers a resident approval and broadcasts cancellation to its active delivery', async () => { const api = createFixtureApi() - // Discover the resident approval's stable rpcId from the mux baseline. const abort = new AbortController() - const seen: { rpcId: string; frame: MuxFrame }[] = [] - const consuming = (async () => { - for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload }) - })() - await vi.waitFor(() => { - expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true) - }) - const requested = seen.find(s => s.frame.type === 'approval/requested') - if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable') - const approvalId = requested.frame.approvalId + const stream = api.remoteEvents(abort.signal) + const iterator = stream[Symbol.asyncIterator]() + const approval = await nextRemoteEvent( + iterator, + frame => isRemoteEventRequest(frame) && frame.event === 'approval/request', + ) + if (!isRemoteEventRequest(approval)) throw new Error('fixture approval Remote Event missing') - // Routed but malformed answers. - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - // The real answer settles the question and broadcasts resolved. - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } })) - .toEqual({ accepted: true }) - await vi.waitFor(() => { - expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true) - }) - // Settled: a duplicate answer is late, and a fresh mux open replays nothing. - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } })) - .toEqual({ accepted: false, reason: 'not-pending' }) + await expect(api.answerRemoteEvent({ + clientId: await stream.clientId, + eventId: approval.eventId, + outcome: { kind: 'result', value: 'allowed-once' }, + })).resolves.toEqual({ ok: true, value: undefined }) + const cancelled = await nextRemoteEvent( + iterator, + frame => isRemoteEventCancellation(frame) && frame.eventId === approval.eventId, + ) + expect(cancelled).toEqual({ type: 'cancel', eventId: approval.eventId }) abort.abort() - await consuming - const abort2 = new AbortController() - const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2) - expect(replayed.some(f => f.type === 'approval/requested')).toBe(false) + await iterator.return?.() + + await expect(api.answerRemoteEvent({ + clientId: await stream.clientId, + eventId: approval.eventId, + outcome: { kind: 'next' }, + })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + const remaining = await readResidentRemoteEvents(api, 1) + expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request']) }) it('describe answers the fixture identity', async () => { @@ -540,11 +1036,10 @@ describe('createFixtureApi', () => { expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) - it('workspace.list serves the resident account and create reuses on path collision', async () => { + it('workspace/follow serves the resident baseline and create reuses on path collision', async () => { const api = createFixtureApi() - const listed = await api.workspace.list(req({})) - if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.items).toEqual([ + const baseline = await readWorkspaceBaseline(api.workspaceRemote) + expect(baseline.items).toEqual([ expect.objectContaining({ workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], @@ -560,16 +1055,15 @@ describe('createFixtureApi', () => { expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) }) - it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => { + it('workspace.create on a fresh path mints a new entity and pushes an upsert', async () => { const api = createFixtureApi() const abort = new AbortController() - const seen: HostFrame[] = [] - const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) { - seen.push(envelope.payload) - abort.abort() - } - })() + const consuming = collectValues( + api.workspaceRemote.follow(abort.signal), + abort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.path === '/tmp/fixture-workspaces/nova'), + ) await new Promise(resolve => setTimeout(resolve, 10)) const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!created.result.ok) throw new Error('create failed') @@ -577,8 +1071,8 @@ describe('createFixtureApi', () => { expect(created.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [], }) - await consuming - expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) + const frames = await consuming + expect(frames.at(-1)).toEqual({ type: 'upsert', workspace: created.result.value.workspace }) // A basename-less path serves as its own title. const rootPath = await api.workspace.create(req({ path: '/' })) if (!rootPath.result.ok) throw new Error('rootPath failed') @@ -588,13 +1082,11 @@ describe('createFixtureApi', () => { it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => { const api = createFixtureApi() const abort = new AbortController() - const seen: HostFrame[] = [] - const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) { - seen.push(envelope.payload) - if (seen.length >= 2) abort.abort() - } - })() + const consuming = collectValues( + api.workspaceRemote.follow(abort.signal), + abort, + frames => frames.filter(frame => frame.type === 'upsert').length >= 2, + ) await new Promise(resolve => setTimeout(resolve, 10)) const wsid = 'fx-ws-fixture' as WorkspaceId const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) @@ -611,22 +1103,28 @@ describe('createFixtureApi', () => { const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' })) if (!renamed.result.ok) throw new Error('rename failed') expect(renamed.result.value.workspace.title).toBe('renamed') - await consuming + const frames = await consuming // Only the create and the effective rename emit frames; the no-op stays silent. - expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed']) + const upserts = frames.filter(frame => frame.type === 'upsert') + expect(upserts).toHaveLength(2) + expect(upserts[1]).toMatchObject({ workspace: { workspaceId: wsid, title: 'renamed' } }) }) it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => { const api = createFixtureApi() - const abort = new AbortController() - const framesPromise = (async () => { - const frames: MuxFrame[] = [] - for await (const envelope of api.events.mux(req({}), abort.signal)) { - frames.push(envelope.payload) - if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort() - } - return frames - })() + const followAbort = new AbortController() + const controlAbort = new AbortController() + const followPromise = collectValues( + api.sessionRemote.follow(sid('fx-alpha'), followAbort.signal), + followAbort, + frames => frames.some(frame => frame.type === 'event' + && (frame.event as { type: string }).type === 'session/title'), + ) + const controlPromise = collectValues( + api.sessionRemote.control(controlAbort.signal), + controlAbort, + frames => frames.some(frame => frame.type === 'projection' && frame.key === 'title' && frame.value === '重命名'), + ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' })) @@ -649,10 +1147,16 @@ describe('createFixtureApi', () => { type: 'session/title', data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } }, }) - // Beyond the subscribe-time baseline replay, the append emitted exactly - // one title projection frame carrying the new value at the response seq. - const frames = await framesPromise - const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名') + const followed = await followPromise + expect(followed.some(frame => frame.type === 'event' + && frame.event.seq === acceptedSeq + && (frame.event as { readonly type: string }).type === 'session/title')).toBe(true) + const frames = await controlPromise + const titleFrames = frames.filter(frame => + frame.type === 'projection' + && frame.key === 'title' + && frame.sessionId === sid('fx-alpha') + && frame.value === '重命名') expect(titleFrames).toHaveLength(1) expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq }) }) @@ -683,23 +1187,20 @@ describe('createFixtureApi', () => { it('workspace.delete removes only the Workspace row and emits the removal frame', async () => { const api = createFixtureApi() const abort = new AbortController() - const seen: HostFrame[] = [] - const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) { - seen.push(envelope.payload) - abort.abort() - } - })() + const consuming = collectValues( + api.workspaceRemote.follow(abort.signal), + abort, + frames => frames.some(frame => frame.type === 'remove'), + ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) - await consuming - expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }]) - const list = await api.workspace.list(req({})) - if (!list.result.ok) throw new Error('workspace list failed') - expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) + const frames = await consuming + expect(frames.at(-1)).toEqual({ type: 'remove', workspaceId: 'fx-ws-fixture' }) + const baseline = await readWorkspaceBaseline(api.workspaceRemote) + expect(baseline.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) const sessions = await api.sessions.list(req({})) if (!sessions.result.ok) throw new Error('session list failed') expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha') @@ -707,14 +1208,23 @@ describe('createFixtureApi', () => { it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() - const abort = new AbortController() - const seen: HostFrame[] = [] + const hostAbort = new AbortController() + const workspaceAbort = new AbortController() + const seen: FixtureRemoteEventNotificationFrame[] = [] const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) { - seen.push(envelope.payload) - if (seen.length >= 2) abort.abort() + for await (const frame of api.remoteEvents(hostAbort.signal)) { + if (frame.type !== 'emit' || frame.event !== 'api-session/added') continue + seen.push(frame) + hostAbort.abort() + break } })() + const workspaceFrames = collectValues( + api.workspaceRemote.follow(workspaceAbort.signal), + workspaceAbort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.sessionIds.length === 4), + ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) @@ -722,30 +1232,44 @@ describe('createFixtureApi', () => { if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId await consuming - // The session lands with the workspace's path as cwd, and the account - // write pushes the fresh workspace snapshot after session-added. const added = seen[0] - if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') - expect(added).toEqual({ - type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture', + expect(added).toMatchObject({ + event: 'api-session/added', + args: [{ sessionId: id, blank: true, cwd: '/tmp/fixture' }], }) - expect(seen[1]).toMatchObject({ - type: 'host/workspace-changed', - workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] }, + expect((await workspaceFrames).at(-1)).toMatchObject({ + type: 'upsert', + workspace: { + workspaceId: 'fx-ws-fixture', + sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'], + }, }) }) - it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => { + it('supports an empty baseline, preallocated ids, independent streams, and idempotent retry', async () => { const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' }) const initialSessions = await api.sessions.list(req({})) - const initialWorkspaces = await api.workspace.list(req({})) expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) - expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) + expect(await readWorkspaceBaseline(api.workspaceRemote)).toEqual({ + items: [], + archivedSessionIds: [], + }) const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!made.result.ok) throw new Error('workspace create failed') - const abort = new AbortController() - const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) + const hostAbort = new AbortController() + const workspaceAbort = new AbortController() + const hostFrames = collectValues( + api.remoteEvents(hostAbort.signal), + hostAbort, + frames => frames.length === 1, + ) + const workspaceFrames = collectValues( + api.workspaceRemote.follow(workspaceAbort.signal), + workspaceAbort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.sessionIds.includes(sid('fx-preallocated'))), + ) await new Promise(resolve => setTimeout(resolve, 10)) const preallocated = sid('fx-preallocated') const created = await api.sessions.create(req({ @@ -753,15 +1277,17 @@ describe('createFixtureApi', () => { sessionId: preallocated, })) expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } }) - const frames = await framesPromise - expect(frames[0]).toMatchObject({ - type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] }, + expect((await workspaceFrames).at(-1)).toMatchObject({ + type: 'upsert', workspace: { sessionIds: [preallocated] }, }) - const added = frames[1] - if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') - expect(added).toEqual({ - type: 'host/session-added', sessionId: preallocated, blank: true, - cwd: made.result.value.workspace.path, + const added = (await hostFrames)[0] + expect(added).toMatchObject({ + event: 'api-session/added', + args: [{ + sessionId: preallocated, + blank: true, + cwd: made.result.value.workspace.path, + }], }) const retried = await api.sessions.create(req({ @@ -792,9 +1318,8 @@ describe('createFixtureApi', () => { workspaceId: 'fx-ws-fixture' as WorkspaceId, }))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } }) - const workspaces = await api.workspace.list(req({})) - if (!workspaces.result.ok) throw new Error('workspace list failed') - expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId) + const workspaces = await readWorkspaceBaseline(api.workspaceRemote) + expect(workspaces.items[0]?.sessionIds).toContain(sessionId) }) it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => { @@ -828,10 +1353,10 @@ describe('createFixtureApi', () => { error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } }, }) const listed = await api.sessions.list(req({})) - const workspaces = await api.workspace.list(req({})) - if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed') + const workspaces = await readWorkspaceBaseline(api.workspaceRemote) + if (!listed.result.ok) throw new Error('list failed') expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) - expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId) + expect(workspaces.items[0]?.sessionIds).not.toContain(sessionId) const retried = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId, @@ -851,10 +1376,10 @@ describe('createFixtureApi', () => { sessionId, })))).rejects.toThrow(/dropped session\.create response/) const listed = await dropped.sessions.list(req({})) - const workspaces = await dropped.workspace.list(req({})) - if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed') + const workspaces = await readWorkspaceBaseline(dropped.workspaceRemote) + if (!listed.result.ok) throw new Error('list failed') expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true) - expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId) + expect(workspaces.items[0]?.sessionIds).toContain(sessionId) await expect(dropped.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId, @@ -891,15 +1416,38 @@ describe('createFixtureApi', () => { // The failure was one-shot: the next call succeeds. const ok = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) expect(ok.result.ok).toBe(true) - // appendUser emits on the mux stream; appendSilent only lands in the log (lost frame). - const abort = new AbortController() - const seen: MuxFrame[] = [] - const consuming = (async () => { - for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push(envelope.payload) - })() - await new Promise(resolve => setTimeout(resolve, 10)) + // A durable append without a live frame creates a detectable seq gap. + const gapAbort = new AbortController() + const gapIterator = api.sessionRemote.follow(sid('fx-alpha'), gapAbort.signal)[Symbol.asyncIterator]() + const opening = await gapIterator.next() + if (opening.done || opening.value.type !== 'opened') throw new Error('follow opening cursor missing') + const resumeCursor = opening.value.cursor hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendUser('fx-alpha', '正常直播') + await expect(gapIterator.next()).rejects.toThrow(/stream skipped seq/) + + // Reopening from the established cursor replays both durable events. + const followAbort = new AbortController() + const controlAbort = new AbortController() + const followed: FixtureFollowFrame[] = [] + const controlled: FixtureControlFrame[] = [] + const following = (async () => { + for await (const frame of api.sessionRemote.follow( + sid('fx-alpha'), + followAbort.signal, + resumeCursor, + )) followed.push(frame) + })() + const controlling = (async () => { + for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlled.push(frame) + })() + await new Promise(resolve => setTimeout(resolve, 10)) + await vi.waitFor(() => { + expect(followed.some(frame => frame.type === 'event' + && JSON.stringify(frame.event.data).includes('静默丢帧'))).toBe(true) + expect(followed.some(frame => frame.type === 'event' + && JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true) + }) hooks.appendTitle('fx-alpha', 'Fixture 修订标题') hooks.beginModelRetry('fx-alpha') hooks.scheduleModelRetry('fx-alpha') @@ -907,33 +1455,28 @@ describe('createFixtureApi', () => { hooks.beginModelRetry('fx-alpha') hooks.cancelModelRetryDuringBackoff('fx-alpha') await vi.waitFor(() => { - expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true) - expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true) - expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true) - expect(seen.some(f => f.type === 'session/event' - && f.event.type === 'turn/end' - && f.event.data.reason.kind === 'aborted')).toBe(true) - expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true) + expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('正常直播'))).toBe(true) + expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'llm/retry')).toBe(true) + expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('重试后的完整回复'))).toBe(true) + expect(followed.some(frame => frame.type === 'event' + && frame.event.type === 'turn/end' + && frame.event.data.reason.kind === 'aborted')).toBe(true) + expect(controlled.some(frame => frame.type === 'projection' + && frame.key === 'title' + && frame.value === 'Fixture 修订标题')).toBe(true) }) - expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) - const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title') - const titleControlIndex = seen.findIndex(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题') - expect(titleControlIndex).toBe(rawTitleIndex + 1) - // But history serves the silent event (the client's repull finds it). + expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'session/title')).toBe(true) + // Paging and resumed follow agree on the recovered durable event. const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) if (!repull.result.ok) throw new Error('repull failed') expect(JSON.stringify(repull.result.value.events)).toContain('静默丢帧') - // breakStreams force-ends BOTH stream kinds without the client abort. - const habort = new AbortController() - const hostConsuming = (async () => { - for await (const _ of api.events.host(req({}), habort.signal)) { /* drain */ } - })() + // breakStreams force-ends follow and control without client aborts. await new Promise(resolve => setTimeout(resolve, 10)) hooks.breakStreams() - await consuming // returns because the stream broke, not because we aborted - await hostConsuming - expect(abort.signal.aborted).toBe(false) - expect(habort.signal.aborted).toBe(false) + await following + await controlling + expect(followAbort.signal.aborted).toBe(false) + expect(controlAbort.signal.aborted).toBe(false) }) it('paces the opt-in reasoning stress hook from an external interval', async () => { @@ -947,8 +1490,8 @@ describe('createFixtureApi', () => { expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/) const abort = new AbortController() try { - const streamed = collect(api.events.mux(req({}), abort.signal), abort, frames => frames.some(frame => ( - frame.type === 'session/event' + const streamed = collectValues(api.sessionRemote.follow(sid('fx-alpha'), abort.signal), abort, frames => frames.some(frame => ( + frame.type === 'event' && frame.event.type === 'assistant/chunk' && frame.event.data.chunk.type === 'reasoning-delta' && frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE') @@ -967,7 +1510,7 @@ describe('createFixtureApi', () => { const frames = await streamed const deltas = frames.flatMap(frame => ( - frame.type === 'session/event' + frame.type === 'event' && frame.event.type === 'assistant/chunk' && frame.event.data.chunk.type === 'reasoning-delta' ? [frame.event.data.chunk.text] @@ -993,18 +1536,16 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect(() => (client as unknown as { doFetch(): Promise }).doFetch()).toThrow(/doFetch must be unreachable/) }) - it('mints request ids, taps all four full forms, and never touches doFetch', async () => { + it('mints request ids and taps unary request/response envelopes without touching doFetch', async () => { const client = new FixtureApiClient() const tapped: RpcMessage[] = [] client.subscribeEnvelopes(batch => tapped.push(...batch)) - const response = await client.sessions.list({}) + const response = await client.host.describe({}) expect(response.result.ok).toBe(true) - await client.respond({ type: 'client-response', rpcId: RpcId('r-x'), result: { ok: true, value: {} } }) await vi.waitFor(() => { const kinds = tapped.map(m => m.type) expect(kinds).toContain('client-request') expect(kinds).toContain('server-response') - expect(kinds).toContain('client-response') }) const request = tapped.find(m => m.type === 'client-request') const reply = tapped.find(m => m.type === 'server-response') @@ -1013,28 +1554,30 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('covers the whole unary dispatch table', async () => { const client = new FixtureApiClient() - expect((await client.sessions.search( + const sessions = createSessionClient(client.rpc) + const workspaces = createWorkspaceClient(client.rpc) + expect((await sessions.search( { query: 'fixture' }, new AbortController().signal, )).result.ok).toBe(true) - const created = await client.sessions.create({}) + const created = await sessions.create({}) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId - expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) - expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) - expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) + expect((await sessions.history({ sessionId: id })).result.ok).toBe(true) + expect((await sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) + expect((await sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) - expect((await client.workspace.list({})).result.ok).toBe(true) - const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' }) + expect((await readWorkspaceBaseline(createWorkspaceRemote(client.rpc))).items).not.toHaveLength(0) + const workspace = await workspaces.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') const wsid = workspace.result.value.workspace.workspaceId - const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' }) + const renamed = await workspaces.rename({ workspaceId: wsid, title: 'via-client-2' }) if (!renamed.result.ok) throw new Error('workspace rename failed') expect(renamed.result.value.workspace.title).toBe('via-client-2') - const attached = await client.sessions.create({ workspaceId: wsid }) + const attached = await sessions.create({ workspaceId: wsid }) if (!attached.result.ok) throw new Error('attached create failed') - const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) + const moved = await workspaces.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) if (!moved.result.ok) throw new Error('workspace move failed') expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) // Goal lifecycle over the fixture fold: create → edit → pause → resume → complete → clear; @@ -1061,7 +1604,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false) expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } }) - const goalHistory = await client.sessions.history({ sessionId: id }) + const goalHistory = await sessions.history({ sessionId: id }) if (!goalHistory.result.ok) throw new Error('goal history failed') const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as { type: string @@ -1082,21 +1625,40 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first', }) const client = new FixtureApiClient() - await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) - const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' }) + const sessions = createSessionClient(client.rpc) + const workspaces = createWorkspaceClient(client.rpc) + const workspaceRemote = createWorkspaceRemote(client.rpc) + await expect(sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) + const made = await workspaces.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') - const abort = new AbortController() - const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) + const hostAbort = new AbortController() + const workspaceAbort = new AbortController() + const hostFrames = collectValues( + openFixtureRemoteEvents(client.rpc, hostAbort.signal), + hostAbort, + frames => frames.length === 1, + ) + const workspaceFrames = collectValues( + workspaceRemote.follow(workspaceAbort.signal), + workspaceAbort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.sessionIds.includes(sid('fx-query-session'))), + ) await new Promise(resolve => setTimeout(resolve, 10)) const sessionId = sid('fx-query-session') - const created = await client.sessions.create({ + const created = await sessions.create({ workspaceId: made.result.value.workspace.workspaceId, sessionId, }) expect(created.result).toMatchObject({ ok: true, value: { sessionId } }) - const frames = await framesPromise - expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added']) - const rejected = await client.sessions.prompt({ + expect((await workspaceFrames).at(-1)).toMatchObject({ + type: 'upsert', + workspace: { sessionIds: [sessionId] }, + }) + expect((await hostFrames)[0]).toMatchObject({ + event: 'api-session/added', + }) + const rejected = await sessions.prompt({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'retain' }], @@ -1107,7 +1669,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('maps attach-failure and dropped-response query scenarios', async () => { vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' }) const partial = new FixtureApiClient() - const partialResult = await partial.sessions.create({ + const partialResult = await createSessionClient(partial.rpc).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-partial'), }) @@ -1118,34 +1680,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) const dropped = new FixtureApiClient() - await expect(dropped.sessions.create({ + await expect(createSessionClient(dropped.rpc).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-dropped'), })).rejects.toThrow(/dropped session\.create response/) }) - it('fires onOpen at stream-iteration start and taps server-request full forms', async () => { - const client = new FixtureApiClient() - const tapped: RpcMessage[] = [] - client.subscribeEnvelopes(batch => tapped.push(...batch)) - const order: string[] = [] - const abort = new AbortController() - for await (const envelope of client.events.mux({}, abort.signal, () => order.push('open'))) { - order.push(envelope.payload.type) - abort.abort() - } - expect(order[0]).toBe('open') - expect(order[1]).toBe('session/subscribed') - await vi.waitFor(() => { - expect(tapped.some(m => m.type === 'server-request')).toBe(true) - }) - // Host stream side of the pair (same tap path). - const habort = new AbortController() - const hostOrder: string[] = [] - const hostIterator = client.events.host({}, habort.signal, () => hostOrder.push('open'))[Symbol.asyncIterator]() - const raced = await Promise.race([hostIterator.next(), new Promise<'idle'>(resolve => setTimeout(() => { resolve('idle') }, 50))]) - expect(hostOrder).toEqual(['open']) // established even though the host stream stays silent - habort.abort() - if (raced === 'idle') await hostIterator.return?.(undefined) - }) }) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 022436d558..504e50d9fd 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -1,7 +1,7 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ -import { EventEmitter, once } from 'node:events' +import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'node:http' -import { PassThrough, Readable } from 'node:stream' +import { Readable } from 'node:stream' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' @@ -10,7 +10,7 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts' +import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts' import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' /** Structural webServer fake recording both route registries. */ @@ -78,6 +78,7 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[] upgrades: WebUpgradeRoute[] + connection: HostConnectionHandle dispose: () => Promise }> { const ctx = new Context() @@ -87,7 +88,12 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, upgrades, dispose: () => fiber.dispose() } + return { + routes, + upgrades, + connection: ctx.get('connection') as HostConnectionHandle, + dispose: () => fiber.dispose(), + } } describe('connection node half', () => { @@ -121,41 +127,16 @@ describe('connection node half', () => { expect(upgrades).toHaveLength(0) }) - it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => { + it('registers only the HTTP route and removes it with the fiber', async () => { const { routes, upgrades, dispose } = await mounted() expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) - expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH]) + expect(upgrades).toHaveLength(0) await dispose() expect(routes).toHaveLength(0) expect(upgrades).toHaveLength(0) }) - it('requires WebSocket upgrade for network GETs to either event path', async () => { - const { routes, dispose } = await mounted() - for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) { - const { response, state } = fakeResponse() - await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response) - expect(state.status).toBe(426) - expect(state.body).toBe('upgrade required') - } - await dispose() - }) - - it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => { - const { upgrades, dispose } = await mounted() - const socket = new PassThrough() - const chunks: Buffer[] = [] - socket.on('data', (chunk: Buffer) => { chunks.push(chunk) }) - const ended = once(socket, 'end') - await upgrades[0]!.handler(fakeRequest({ - host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', - }, MUX_EVENTS_PATH), socket, Buffer.alloc(0)) - await ended - expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden') - await dispose() - }) - it('refuses an untrusted Host on any /api path before the bridge runs', async () => { const { routes, dispose } = await mounted() const { response, state } = fakeResponse() @@ -219,6 +200,17 @@ describe('connection node half', () => { await dispose() }) + it('shares its configured trust policy with sibling routes', async () => { + const { connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) + const loopback = fakeRequest({ host: '127.0.0.1:3080' }) + const declared = fakeRequest({ host: 'harness.example' }) + + expect(connection.isTrustedRequest(loopback, 'loopback')).toBe(true) + expect(connection.isTrustedRequest(declared, 'loopback')).toBe(false) + expect(connection.isTrustedRequest(declared, 'trusted-host')).toBe(true) + await dispose() + }) + it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { const ctx = new Context() const routes: WebRoute[] = [] diff --git a/packages/client/connection/tests/websocket-downlink.host.spec.ts b/packages/client/connection/tests/websocket-downlink.host.spec.ts deleted file mode 100644 index fecd7ea224..0000000000 --- a/packages/client/connection/tests/websocket-downlink.host.spec.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { once } from 'node:events' -import { createServer } from 'node:http' -import type { AddressInfo } from 'node:net' -import { afterEach, describe, expect, it, vi } from 'vitest' -import WebSocket from 'ws' -import type { - ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, -} from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' -import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts' -import { WebSocketDownlinks } from '../src/websocket-downlink.ts' - -type MuxSource = (signal: AbortSignal) => AsyncIterable> -type HostSource = (signal: AbortSignal) => AsyncIterable> - -const running: (() => Promise)[] = [] - -afterEach(async () => { - await Promise.all(running.splice(0).map(close => close())) -}) - -function untilAbort(signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) -} - -async function * idle(signal: AbortSignal): AsyncGenerator> { - await untilAbort(signal) -} - -function api(mux: MuxSource, host: HostSource): ApiProxy { - return { - events: { - mux: (_request, signal) => mux(signal), - host: (_request, signal) => host(signal), - }, - } as ApiProxy -} - -async function serve(downlinks: WebSocketDownlinks): Promise<{ - origin: string - close: () => Promise -}> { - const server = createServer() - server.on('upgrade', (request, socket, head) => { - const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname - if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head) - else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head) - else socket.destroy() - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const port = (server.address() as AddressInfo).port - return { - origin: `ws://127.0.0.1:${String(port)}`, - close: async () => { - await downlinks.close() - await new Promise(resolve => server.close(() => { resolve() })) - }, - } -} - -function read(socket: WebSocket): Promise { - return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest) -} - -async function acceptedSocket(downlinks: WebSocketDownlinks): Promise { - const server = (downlinks as unknown as { server: { clients: Set } }).server - let accepted: WebSocket | undefined - await vi.waitFor(() => { - accepted = server.clients.values().next().value - expect(accepted).toBeDefined() - }) - return accepted as WebSocket -} - -describe('WebSocket downlinks', () => { - it('carries mux and host over independent downstream sockets and cancels each source on close', async () => { - let muxAborted = false - let hostAborted = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - yield { - rpcId: RpcId('mux-1'), - payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 }, - } - await untilAbort(signal) - } finally { - muxAborted = true - } - }, - async function * (signal) { - try { - yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } } - await untilAbort(signal) - } finally { - hostAborted = true - } - }, - )) - const host = await serve(downlinks) - running.push(host.close) - - const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`) - const muxFrame = read(mux) - const hostFrame = read(hostSocket) - expect(await muxFrame).toEqual({ - type: 'server-request', - rpcId: 'mux-1', - method: 'session/subscribed', - payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 }, - }) - expect(await hostFrame).toEqual({ - type: 'server-request', - rpcId: 'host-1', - method: 'host/remote-event', - payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, - }) - - const muxClosed = once(mux, 'close') - const hostClosed = once(hostSocket, 'close') - mux.close() - hostSocket.close() - await Promise.all([muxClosed, hostClosed]) - await vi.waitFor(() => { - expect(muxAborted).toBe(true) - expect(hostAborted).toBe(true) - }) - }) - - it('rejects client messages because upstream remains HTTP', async () => { - let aborted = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - await untilAbort(signal) - } finally { - aborted = true - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const closed = once(socket, 'close') - socket.send('upstream payload') - const [code, reason] = await closed as [number, Buffer] - expect(code).toBe(1008) - expect(String(reason)).toBe('downlink only') - await vi.waitFor(() => { expect(aborted).toBe(true) }) - }) - - it('sends stream/error before closing when a source fails', async () => { - const downlinks = new WebSocketDownlinks(api( - async function * () { - throw new Error('mux source failed') - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - const failure = read(socket) - const closed = once(socket, 'close') - expect((await failure).payload).toEqual({ - type: 'stream/error', - error: { code: 'internal', message: 'Error: mux source failed', details: {} }, - }) - await closed - }) - - it('aborts the source when an accepted socket reports a transport error', async () => { - let aborted = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - await untilAbort(signal) - } finally { - aborted = true - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const accepted = await acceptedSocket(downlinks) - const closed = once(socket, 'close') - accepted.emit('error', new Error('transport failed')) - await closed - expect(aborted).toBe(true) - }) - - it('drops a source frame that races after the client has closed', async () => { - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - let finish!: () => void - const finished = new Promise((resolve) => { finish = resolve }) - let sourceSignal: AbortSignal | undefined - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - sourceSignal = signal - try { - await gate - yield { - rpcId: RpcId('late'), - payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 }, - } - } finally { - finish() - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const closed = once(socket, 'close') - socket.close() - await closed - await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) - release() - await finished - }) - - it('contains socket send callback failures and closes the downlink', async () => { - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - const downlinks = new WebSocketDownlinks(api( - async function * () { - await gate - yield { - rpcId: RpcId('send-failure'), - payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 }, - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const accepted = await acceptedSocket(downlinks) - const send = vi.spyOn(accepted, 'send').mockImplementation((( - _data: unknown, - optionsOrCallback?: unknown, - callback?: (error?: Error) => void, - ) => { - const done = typeof optionsOrCallback === 'function' - ? optionsOrCallback as (error?: Error) => void - : callback - done?.(new Error('socket send failed')) - }) as WebSocket['send']) - const closed = once(socket, 'close') - release() - await closed - expect(send).toHaveBeenCalledTimes(2) - send.mockRestore() - }) - - it('rejects when its acceptor has already closed', async () => { - const downlinks = new WebSocketDownlinks(api(idle, idle)) - await downlinks.close() - await expect(downlinks.close()).rejects.toThrow('The server is not running') - }) - - it('waits for source cleanup before teardown resolves', async () => { - let cleanupStarted!: () => void - const started = new Promise((resolve) => { cleanupStarted = resolve }) - let releaseCleanup!: () => void - const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve }) - let cleaned = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - await untilAbort(signal) - } finally { - cleanupStarted() - await cleanupGate - cleaned = true - } - }, - idle, - )) - const host = await serve(downlinks) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - let closed = false - const closing = host.close().then(() => { closed = true }) - try { - await started - expect(closed).toBe(false) - releaseCleanup() - await closing - expect(cleaned).toBe(true) - } finally { - releaseCleanup() - await closing - } - }) -}) diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index 8e16ec834a..ed5305797d 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -13,8 +13,7 @@ "src/invariant.ts", "src/loopback-hostname.ts", "src/rpc-host.ts", - "src/rpc.ts", - "src/websocket-downlink.ts" + "src/rpc.ts" ], "references": [ { diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 8ab9691fc9..c9748afaba 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -33,7 +33,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" ], @@ -46,7 +46,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^" @@ -54,9 +54,10 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 5b1d6c72b4..11530ec015 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -9,15 +9,16 @@ * ui-slots): in THIS unit the map holds only this package's own merges, but * consumers merge more namespaces in and the intersection keeps them * string-typed. The rule fires on the narrow-map view, not real redundancy. */ -import type { Context } from '@deepseek-ai/cordis' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: the ctx.settingsScope Context merge and the settings slot types. // Cross-plugin collaboration goes through the service, never a value import // (client bundle purity gate). -import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings, } from '../locale-settings.ts' @@ -146,7 +147,7 @@ export class LocaleRuntime { private bound = new Map() private snapshot: LocaleSnapshot private listeners = new Set<() => void>() - private readonly ctx: Context + private readonly ctx: ClientContext private readonly host: SettingsScope | undefined /** Browser-derived locale standing wherever no explicit Host selection does. */ private readonly provisional: LocaleId @@ -157,7 +158,7 @@ export class LocaleRuntime { * @param host - durable preference scope owned by the providing plugin; * absent compositions (standalone dictionary registries) stay process-local. */ - constructor(ctx: Context, host?: SettingsScope) { + constructor(ctx: ClientContext, host?: SettingsScope) { this.ctx = ctx this.host = host this.provisional = resolveInitialLocale() diff --git a/packages/client/locale/src/client/settings-store.ts b/packages/client/locale/src/client/settings-store.ts index 485fd409f0..68b19e4b0f 100644 --- a/packages/client/locale/src/client/settings-store.ts +++ b/packages/client/locale/src/client/settings-store.ts @@ -3,7 +3,7 @@ * plugin's apply-world change listener is the only writer; the row component * reads via props.useStore. */ -import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' /** One selectable locale row (id + self-described label). */ export interface LanguageOptionRow { diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index 3bce9617e6..b5f92e0543 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -3,7 +3,7 @@ * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { @@ -45,11 +45,10 @@ async function bench() { } }) ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) - // The settings transport and the forwarded-event port the plugin injects. - new TestRemote(ctx) + const events = new TestRemote(ctx) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { - ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, + ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, events, setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, } } @@ -136,19 +135,19 @@ describe('locale apply', () => { // Preference must differ from the provisional locale (FALLBACK_LOCALE = en // with no window), or clearing it below would be unobservable. b.setHostPreference('zh') - b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) // Cleared preference falls back to the provisional locale. b.setHostPreference(undefined) - b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) // Re-selecting zh after the clear is an explicit pick of the provisional // value and must persist as a written preference. b.setHostPreference('zh') - b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) expect(b.describe).toHaveBeenCalledTimes(4) }) diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index 2c4393aeee..c8a347432e 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -10,7 +10,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/locale/tests/invariant.client.spec.ts b/packages/client/locale/tests/invariant.client.spec.ts index d089441f4a..d9b1eb041e 100644 --- a/packages/client/locale/tests/invariant.client.spec.ts +++ b/packages/client/locale/tests/invariant.client.spec.ts @@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' import { apply as clientApply, COMMON_NS, LocaleRuntime, inject } from '@deepseek-ai/dsh-client-locale/client' import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index d1a0379912..44ddb31e7b 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -1,7 +1,9 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx' @@ -17,13 +19,16 @@ function emptySessions() { return bindSnapshotSelector(store) } function emptyWorkspaces() { - const store = createSnapshotStore({ + const store = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: LanguageRowComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) + function mount(active = 'en') { // Real store instance — the sanctioned zero-machinery path for tests. const store = createLanguageRowStore().create() @@ -31,6 +36,7 @@ function mount(active = 'en') { const setLocale = vi.fn() const props: LanguageRowComponentProps = { useSessions: emptySessions(), + useSessionPendingInteraction, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 8f2ac29049..f2dd8c6f0c 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -9,7 +9,10 @@ ], "references": [ { - "path": "../runtime" + "path": "../store" + }, + { + "path": "../ui-renderer" }, { "path": "../ui-primitives" diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 58800dda2a..ea81e37346 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -222,16 +222,13 @@ export function orderByModuleGraph(entries: readonly WebBootEntry[]): WebBootEnt /** Bootstrap package whose ordinary client bundle supplies the module-system implementation. */ const CLIENT_MODULES_ID = '@deepseek-ai/dsh-client-modules' -/** Dynamic package whose ordinary client bundle must be registered before plugin boot starts. */ -const CLIENT_RUNTIME_ID = '@deepseek-ai/dsh-client-runtime' - /** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */ -const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, CLIENT_RUNTIME_ID] as const +const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID] as const /** * The boot protocol as index injection rows. The inline registration queue - * precedes blocking classic scripts for modules' and runtime's ordinary - * `lib/client.js` artifacts. Its `create()` method materializes the modules + * precedes the blocking classic script for modules' ordinary `lib/client.js` + * artifact. Its `create()` method materializes the modules * bundle, delegates construction to that bundle, and leaves the same facade * in live-registration mode. The graph global follows before the shell reads * it. diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 8577d2a57d..427c9da0bd 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -14,7 +14,7 @@ import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts' const MODULES_ID = '@deepseek-ai/dsh-client-modules' -const RUNTIME_ID = '@deepseek-ai/dsh-client-runtime' +const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer' let root: string | undefined @@ -99,7 +99,7 @@ const bootGraph = (): WebBootGraph => ({ rev: 'graph', entries: [ { id: MODULES_ID, url: '/plugins/modules.js?rev=m', rev: 'm' }, - { id: RUNTIME_ID, url: '/plugins/runtime.js?rev=r', rev: 'r' }, + { id: UI_RENDERER_ID, url: '/plugins/ui-renderer.js?rev=r', rev: 'r' }, ], }) @@ -109,22 +109,22 @@ describe('HTML bootstrap facade', () => { const { html, target } = injectedFacade(graph) const facadeAt = html.indexOf('window.__ModuleLoader__=') const modulesAt = html.indexOf('') - const runtimeAt = html.indexOf('') const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ') const entryAt = html.indexOf('') - expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([ - facadeAt, modulesAt, runtimeAt, graphAt, entryAt, + expect(html).not.toContain('') + expect([facadeAt, modulesAt, graphAt, entryAt]).toEqual([...new Set([ + facadeAt, modulesAt, graphAt, entryAt, ])].sort((a, b) => a - b)) target.load({ id: MODULES_ID, factory: () => modulesClient }) - target.load({ id: RUNTIME_ID, factory: () => ({ marker: 'runtime' }) }) + target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) }) const system = target.create({ boot: graph, staticModules: {} }) expect(target.mode).toBe('live') expect(target.pendingQueue).toEqual([]) expect(system.manifest.rev).toBe('graph') expect(await system.import(MODULES_ID)).toBe(modulesClient) - expect(await system.import(`${RUNTIME_ID}/client`)).toEqual({ marker: 'runtime' }) + expect(await system.import(`${UI_RENDERER_ID}/client`)).toEqual({ marker: 'ui-renderer' }) expect(() => target.create({ boot: graph, staticModules: {} })) .toThrow('create called after module-system boot') }) diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md deleted file mode 100644 index 75c409fa3f..0000000000 --- a/packages/client/runtime/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# @deepseek-ai/dsh-client-runtime - -English | [中文](README.zh.md) - -Client cordis boot and React-free object services: SlotRegistry wraps SlotCore and supplies renderer data sources; SessionRuntime owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspaceRuntime depends on SessionRuntime and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. - -For each prompt that can reach a local root or continuable child Agent, the runtime samples the browser's current `Intl.DateTimeFormat().resolvedOptions().timeZone` and attaches it to that one Session or subagent prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state. - -Settings owners share the React-free `SettingsScopeSpec`, `SettingsScope`, and snapshot types defined here. ui-settings owns `ctx.settingsScope.bind(spec)`, its Host transport, schema validation, and lifecycle; see [its package contract](../ui-settings/README.md). - -## Slot declaration injection - -`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws. - -The callback returns one synchronous disposer or an iterable of disposers. A generator can therefore yield several `slots.register()` calls as one transaction: setup failure rolls earlier yields back and teardown runs them in reverse order. Declaration lifetimes use a dedicated monotonic epoch, so a collapse and redeclaration batched into one renderer notification still restarts the callback, while ordinary entry changes do not. Declaration-bound teardown runs synchronously with the ledger mutation, releasing runtime resources before subsequent same-tick registrations. See the [declaration-injection decision](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md). - -## Workspace and Session lists - -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspaceRuntime.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. - -`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending. - -`WorkspaceRuntime.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. - -`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspaceRuntime.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. - -SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; ui-renderer creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. - -`abbreviateHomePath` is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged. - -`indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. - -`SessionListState.jobsBySession` mirrors the Host's `session/jobs` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order. - -`SessionRuntime.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. - -## New Session and the blank mirror - -`WorkspaceRuntime.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionRuntime.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. - -`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it. - -## Pending queue projection - -`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. - -## Conversation assembly - -Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair. - -Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path. - -`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. `ui-trajectory` registers independent Definitions and a target builder over the same Session window; it preserves the existing stage-oriented view model without consuming the Chat compatibility fields or running another history fold. - -The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). - -## Trajectory request data - -Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A cancellation-finalized `assistant/message` retains its durable result seq and provider provenance but does not complete the request; `step/end` classifies that request as an error. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. - -## Code Mode child-call tree - -Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. Trajectory's Tool Definition independently assembles the same nested data contract for its target. - -## Session title projection - -`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op. - -## Model retry projection - -The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed attempt's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error projects one `turn-error` node from its durable message and optional code — after exhausted retries it renders beside the settled retry notice; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. An intermediate failure that scheduled another retry keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error. - -A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh and restore. - -## Session forking - -`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child. - -## Session model selection - -Each resident `Session` owns a `modelSelection` snapshot containing the current `ModelSelection`, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current selection, opening a selector refreshes the directory, and selection failures preserve the last selection and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the selection reported by the Host without replacing unchanged selection substructure. - -## Model Experience - -None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content. - -#### KV Cache effect - -Changing the model selection can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself. - -## Known Limitations and Deferred Work - -- **`loader.unload` is a stub** — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal. -- **Scope teardown is stage-driven and single-occupant** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land. -- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md deleted file mode 100644 index 3aca97e7e6..0000000000 --- a/packages/client/runtime/README.zh.md +++ /dev/null @@ -1,97 +0,0 @@ -# @deepseek-ai/dsh-client-runtime - -[English](README.md) | 中文 - -客户端 cordis 启动与不依赖 React 的对象服务:SlotRegistry 包装 SlotCore 并提供 renderer 数据源;SessionRuntime 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspaceRuntime 依赖 SessionRuntime,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 - -对于每条可到达本地根 Agent 或可继续子 Agent 的提示词,运行时都会采样浏览器当前的 `Intl.DateTimeFormat().resolvedOptions().timeZone`,并只把该值附加到这一次 Session 或 subagent 提示词 RPC。该值既不缓存,也不包含在 Session 创建或 fork 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替。 - -设置所有者共用本包定义的不依赖 React 的 `SettingsScopeSpec`、`SettingsScope` 与快照类型。ui-settings 拥有 `ctx.settingsScope.bind(spec)`、对应的 Host 传输、schema 校验与生命周期;详见[该包的约定](../ui-settings/README.zh.md)。 - - - -## Slot 声明注入 - -`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 - -回调返回一个同步 disposer 或由多个 disposer 构成的 iterable。因此,generator 可以 yield 多个 `slots.register()` 调用,并将它们组成一项事务:setup 失败会回滚先前 yield 的 effect,teardown 则按逆序运行它们。声明生命周期使用专用的单调 declaration epoch(声明代次),因此,即使折叠与重新声明合并在同一次 renderer 通知中,回调仍会重启,而普通条目变更不会重启它。声明绑定的 teardown 与账本变更同步运行,在同一 tick 内的后续注册之前释放运行时资源。详见 [slot 声明注入决策](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md)。 - -## Workspace 与 Session 列表 - -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspaceRuntime.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 - -`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 - -`WorkspaceRuntime.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 - -`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspaceRuntime.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 - -SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;ui-renderer 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。 - -`abbreviateHomePath` 是 Web Workspace 悬停卡片与 Tool 摘要使用的仅展示 POSIX 家目录缩写;Windows 盘符或 UNC 路径保持原样,缺失、空或文件系统根的 home 不改写路径。 - -`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 - -`SessionListState.jobsBySession` 按 last-wins 镜像宿主的 `session/jobs` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。 - -`SessionRuntime.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 - -## New Session 与 blank 镜像 - -`WorkspaceRuntime.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionRuntime.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 - -`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。 - -## 待处理队列投影 - -`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则可能呈现 `queue-item-not-found`。 - -## Conversation 组装 - -每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。 - -Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id,并保证 update 能按日志 `seq` 回放;renderer 只消费最终 Node data 与受限 Location value,不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.zh.md)。 - -`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。`ui-trajectory` 在同一个 Session 窗口上注册独立 Definition 与 target builder;它保留现有的 stage-oriented view model,既不消费 Chat 兼容字段,也不运行另一套 history fold。 - -Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md)。 - -## Trajectory 请求数据 - -Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。由取消定稿的 `assistant/message` 会保留持久结果 seq 和提供方信息,但不会将请求标记为完成;`step/end` 会把该请求归类为错误。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 - -## Code Mode 子调用树 - -每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。Trajectory 的 Tool Definition 为自己的 target 独立组装同一种嵌套数据契约。 - -## Session 标题投影 - -`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。 - -## 模型重试投影 - -Host 所属的 LLM(大语言模型)retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry` 与 `llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败尝试的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`;如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`,started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点——重试耗尽后它与定格的重试提示并列渲染;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。安排了下一次重试的中间失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。 - -reason 为 `max-tokens` 的 `turn/end` 会在该轮位置投影出一个 `turn-max-tokens` 节点:一条 warning 样式的本地化提示,说明回答在单次请求的输出 token 上限处停止,已截断的输出保留在对话流中,并提示发送“继续”可在新一轮接着输出。事件本身不携带 token 数量,提示因此不显示任何数字。窗口重建与历史回放使用同一 Definition 重建该节点,刷新和恢复后结束原因保持一致。 - -## 会话 fork - -`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端根据源会话的持久化标题重命名子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 - -## 会话模型选择 - -每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前模型选择、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前模型选择,打开选择器会刷新目录;选择失败会保留上一次模型选择和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的模型选择。重连重建会恢复 Host 报告的模型选择,同时不替换未变化的选择子结构。 - -## 模型体验 - -无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。 - -#### KV Cache 影响 - -更改模型选择可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。 - -## 已知限制与暂缓事项 - -- **`loader.unload` 是 stub**:它会抛出 not-implemented;客户端没有从 fiber dispose 到注册与样式移除的卸载链。 -- **scope 拆卸由阶段驱动且只有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 -- **插件 bundle 从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json deleted file mode 100644 index fd2e7faff2..0000000000 --- a/packages/client/runtime/package.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-client-runtime", - "description": "Client core services: SlotRegistry, SessionRuntime (scope tree + object layer)", - "version": "0.1.1-rc.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/runtime" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-typert-registry", - "@deepseek-ai/dsh-api-remotes" - ], - "platform": "web", - "immediately": true - } - }, - "license": "MIT", - "dependencies": { - "immer": "^10.1.1", - "zustand": "~4.4.7" - }, - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "react": "^18.2.0", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/client.js", - "lib/types/**/*.d.ts" - ] -} diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts deleted file mode 100644 index 1026d271b2..0000000000 --- a/packages/client/runtime/src/client/contract/sessions-port.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Cross-domain sessions face consumed by the workspace domain instead of the - * sessions implementation. The sessions domain satisfies it structurally — - * SessionRuntime is assignable, checked - * wherever the assembly layer or a test injects the real service — so - * widening this face is the explicit act of widening the inter-domain - * dependency. - */ - -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-api-remotes/client' -import type { ObservableSnapshot } from './store.ts' - -/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */ -export interface SessionsPortSummary { - id: SessionId - /** Empty-log bit (blank sessions are reused by New Session instead of minting another). */ - blank: boolean - cwd?: string - updatedAt: number -} - -/** Session-list facts sibling domains read: readiness, selection, and the row map. */ -export interface SessionsPortList { - ids: SessionId[] - byId: Record - current: SessionId | undefined - phase: 'pending' | 'ready' -} - -/** The sessions-service face injected into sibling domains. */ -export interface SessionsPort { - /** Observable list snapshot (read face only; writes stay inside the sessions domain). */ - readonly list: ObservableSnapshot - /** - * Create a session on the host. - * @param opts - target workspace. - * @returns the new session id. - */ - create(opts: { workspaceId: WorkspaceId }): Promise - /** - * Select a session as current. - * @param id - session id (must exist in the list store). - */ - open(id: SessionId): void - /** Clear the current selection into the no-session view state. */ - clear(): void -} diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts deleted file mode 100644 index 4012086c3d..0000000000 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * The outward workspaces-service face — what `ctx.workspaces` exposes to - * feature packages and the renderer host, and therefore exactly what the - * test runtime's workspaces double must implement. Wire-pump entry points - * (handleHostEnvelope/handleConnected/refresh/startInitialSelection) stay on - * the concrete class. Widening this interface is the explicit act of - * widening what features may do to the workspaces domain. - */ -import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client' -import type { WorkspaceListState } from '../workspaces/service.ts' -import type { ObservableSnapshot } from './store.ts' - -/** The workspaces-service face injected as `ctx.workspaces`. */ -export interface IWorkspaces { - /** The useWorkspaces standard feed (read face — writes stay inside the domain). */ - readonly list: ObservableSnapshot - /** - * Connect a Workspace to its reusable or freshly created blank session. - * @param workspaceId - target workspace. - * @returns the connected session id. - */ - connectWorkspace(workspaceId: WorkspaceId): Promise - /** - * The New Session flow: connect the explicit, current-Session, or recent - * Workspace and open the resulting session; failures surface on the session - * list state. - * @param workspaceId - explicit target; omitted inherits the current - * Session's Workspace before falling back to the recency projection. - */ - startSession(workspaceId?: WorkspaceId): void - /** - * Register an existing path as a Workspace. - * @param input - the Host create payload. - * @returns the created or idempotently resolved Workspace. - */ - create(input: { path: string }): Promise - /** - * Open the Host's native directory picker. - * @returns the selected path, or null when the user cancelled. - */ - pickDirectory(): Promise - /** - * List one directory level through the Host's `browse` capability. - * @param path - absolute directory to list; absent lists the Host home directory. - * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. - * @returns the level's listing with breadcrumb ancestry. - */ - listDirectory(path?: string, signal?: AbortSignal): Promise - /** - * Create one child directory through the Host's `browse` capability. - * @param path - absolute existing parent directory. - * @param name - single non-blank path segment. - * @returns the created directory's absolute path. - */ - createDirectory(path: string, name: string): Promise - /** - * Open a filesystem path with the Host operating system's default application. - * @param path - absolute or host-resolvable path. - */ - openPath(path: string): Promise - /** - * Rename a Workspace. - * @param workspaceId - target workspace. - * @param title - the new display title. - * @returns the updated Workspace view. - */ - rename(workspaceId: WorkspaceId, title: string): Promise - /** - * Delete a Workspace (its sessions fall back to the unaccounted group). - * @param workspaceId - target workspace. - */ - delete(workspaceId: WorkspaceId): Promise - /** - * Move a Workspace within the registry display order. - * @param workspaceId - Workspace to move. - * @param beforeWorkspaceId - Anchor workspace; omitted appends. - */ - insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise - /** - * Move an accounted session within/into a Workspace's ordered list. - * @param workspaceId - target workspace. - * @param sessionId - accounted session to move. - * @param beforeSessionId - accounted anchor to insert before; omitted appends. - * @returns the updated Workspace view. - */ - insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise - /** - * Archive a session into the registry-global set (hidden from grouping - * surfaces; session log and accounting slot remain). Archiving the current - * session clears the selection into the New Session view state. - * @param sessionId - session to archive. - */ - archiveSession(sessionId: SessionId): Promise -} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts deleted file mode 100644 index d5d6333e14..0000000000 --- a/packages/client/runtime/src/client/index.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ -import type { Context } from '@deepseek-ai/cordis' -import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client' -// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather -// than api-remotes': that face imports a Host-tsdown-generated artifact, and this -// project sits in the Host build graph. -import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { TypertContext } from '@deepseek-ai/dsh-typert-protocol' -import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from './slots.ts' -import { SessionRuntime } from './sessions/service.ts' -import type { SessionListState } from './sessions/service.ts' -import { WorkspaceRuntime } from './workspaces/service.ts' -import type { ConversationSnapshot } from './sessions/conversation.ts' -import type { UseProjection } from './sessions/projection-store.ts' -import { ConversationEventRegistry } from './conversation/event-registry.ts' -import { ConversationViewRegistry } from './conversation/view-registry.ts' - -export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' - -export { SlotRegistry } from './slots.ts' -export { ConversationEventRegistry } from './conversation/event-registry.ts' -export { ConversationViewRegistry } from './conversation/view-registry.ts' -export { ConversationNodeAssembler } from './sessions/conversation-assembler.ts' -export { ConversationLocationIndex } from './sessions/conversation-location-index.ts' -export { conversationContextKey } from './contract/conversation.ts' -export type { - ChatConversationViewNode, ConversationContextReader, ConversationEventInput, - ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore, - ConversationStepDataMap, - ConversationLocation, ConversationMatch, ConversationMatchResult, - ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, - ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder, - ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap, - ConversationViewSnapshotStore, StepLocation, TurnLocation, -} from './contract/conversation.ts' -export type { ConversationRuntime } from './sessions/conversation-assembler.ts' -export type { RootOwnerProps } from './slots.ts' -export { SessionCreateError, SessionRuntime, scopeOf, workspaceTitleOf } from './sessions/service.ts' -export { indexSubagentDescendants } from './sessions/subagent-lineage.ts' -export type { SubagentDescendantSummary } from './sessions/subagent-lineage.ts' -export { SessionProvideChannel } from './sessions/provide.ts' -export type { SessionProvideChannelHost } from './sessions/provide.ts' -export { createScope } from './agents/scope.ts' -export type { AgentScopeHandle } from './agents/scope.ts' -export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts' -export { abbreviateHomePath, resolveWorkspacePath } from './workspaces/path.ts' -// Contract only: the scope implementation and its Host transport belong to -// dsh-client-ui-settings (see that package's settings-scope.ts). -export type { - SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec, -} from './contract/settings-scope.ts' -export type { Session } from './sessions/session.ts' -export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' -export type { AgentContext, ISessions } from './contract/sessions.ts' -export type { IWorkspaces } from './contract/workspaces.ts' -export type { - SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, -} from './sessions/service.ts' -export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts' -export type { SubagentAddress, JobView } from '@deepseek-ai/dsh-client-connection/client' -export type { WorkspaceListPhase } from './workspaces/manager.ts' -export type { WorkspaceListState } from './workspaces/service.ts' -export type { - DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-connection/client' -// Runtime owns the snapshot store; ui-renderer only binds it to React. -export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' -export type { - EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, -} from './contract/store.ts' -export type { - AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, - AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, - CommandNode, CompactionSummaryNode, ComposerPhase, - ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - LegacyConversationSlice, PartialAssistant, RunningToolCall, - SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, - UnknownSurfaceNode, UserMessageNode, -} from './sessions/conversation.ts' -export { - EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks, -} from './sessions/conversation.ts' -export { emptyAssistantBlock } from './sessions/partial.ts' -export { isTokenDelta } from './sessions/assistant-timing.ts' -export { contextForm, contextProvenance, sessionRecallLabels } from './sessions/context-provenance.ts' -export { displayFailureMessage } from './sessions/failure-display.ts' -export type { - ConversationContext, ConversationContextOriginKind, -} from './sessions/conversation-context.ts' -export type { - ContextProvenanceView, ContextRole, KnownContextForm, -} from './sessions/context-provenance.ts' -export type { - ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, -} from './sessions/request-inspection.ts' -export { PendingWait } from './sessions/pending.ts' -export type { - PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads, -} from './sessions/pending.ts' -// Projection value store (push model; see the session-projection subsystem -// page, docs/subsystems/session-projection.md): host-computed -// whole values per key; domains ship projection support with zero client code. -export type { - ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection, -} from './sessions/projection-store.ts' -export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' - -/** Client-side Cordis context after declaration merging. */ -export type ClientContext = Context - -declare module '@deepseek-ai/dsh-typert-protocol' { - interface TypertContextMap { - /** Client Agent scope identity; the agent and session share one wire id. */ - agent: TypertContext - } -} - -/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */ -export type UseConversationSession = SnapshotSelectorHook - -declare module '@deepseek-ai/dsh-client-ui-slots' { - /** - * Session standard kit, real members (ui-slots declares the empty seat; - * the runtime — where the subjects live — merges the concrete types): - * every session-scope slot component receives these from the framework. - */ - interface SessionStandardProps { - useSession: SnapshotSelectorHook - /** The framework-resolved session id (owners never pass it). */ - sessionId: SessionId - /** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */ - useProjection: UseProjection - } - /** Standard kit for slots that remain mounted while current session changes. */ - interface SessionMaybeStandardProps { - useSession: MaybeSnapshotSelectorHook - /** Current session id; absent in the no-session state. */ - sessionId: SessionId | undefined - /** Key-addressed projection reader; every key reads absent while no session is current. */ - useProjection: UseProjection - } - /** Props injected into every global slot component. */ - interface GlobalStandardProps { - useSessions: SnapshotSelectorHook - /** Selector hook over real Workspaces and their independent baseline lifecycle. */ - useWorkspaces: SnapshotSelectorHook - } -} - -declare module '@deepseek-ai/cordis' { - interface Events { - /** - * A slot's definition or registration set changed. - * @mode emit - * @param key - the mutated SlotMap key. - */ - 'slots/changed'(key: string): void - /** - * A connection generation was (re-)established. Wire-derived caches must - * treat their state as stale and repull (commands directory; the queue - * mirrors reset themselves through the session resync path). - * @mode emit - */ - 'connection/reset'(): void - } - interface Context { - slots: import('./slots.ts').SlotRegistry - /** Event-to-business-Context Definition registry. */ - conversationEvents: import('./conversation/event-registry.ts').ConversationEventRegistry - /** Per-target Conversation snapshot builder registry. */ - conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry - /** The outward face only; the concrete service stays inside the runtime. */ - sessions: import('./contract/sessions.ts').ISessions - /** The outward face only; the concrete service stays inside the runtime. */ - workspaces: import('./contract/workspaces.ts').IWorkspaces - } -} - -/** Required services: the wire handle and Client Typert registry. */ -export const inject = ['connection', 'typert', 'remote', 'remote.commands'] - -/** Mounts the browser runtime services and connection stream. - * @param ctx - Client Cordis context. - */ -export function apply(ctx: Context): void { - ctx.plugin(SlotRegistry) - const conversation = { - events: new ConversationEventRegistry(ctx), - views: new ConversationViewRegistry(ctx), - } - const connection = ctx.get('connection') as ConnectionHandle - const sessions = new SessionRuntime(ctx, connection.api, ctx.remote, conversation) - ctx.typert.contexts.registerClient('agent', { - identity: candidate => sessions.scopeOf(candidate), - }) - const workspaces = new WorkspaceRuntime(ctx, connection.api, sessions) - ctx.effect( - () => workspaces.startInitialSelection(), - 'runtime: initial Workspace selection', - ) - const loop = connection.start({ - onMuxEnvelope: (envelope) => { - sessions.handleMuxEnvelope(envelope) - }, - onHostEnvelope: (envelope) => { - sessions.handleHostEnvelope(envelope) - workspaces.handleHostEnvelope(envelope) - // Forwarded-event bridge: the session layer ignores registry frames (no - // session routing). This plugin owns the frame sink, so it hands the - // decoded frame straight to the Remote service, which fans it out to - // `ctx.remote.$on` subscribers; no consumer reads a frame. - const frame = envelope.payload - if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args) - }, - onConnected: () => { - sessions.handleConnected() - workspaces.handleConnected() - ctx.emit('connection/reset') - }, - onStateChange: (state) => { - // Generation death fires before any next-generation frame can arrive - // (reconnect replays flow from stream open, ahead of onConnected): - // the only safe moment to drop generation-scoped interaction state. - if (state === 'reconnecting') { - sessions.handleDisconnected() - } - }, - }) - ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') -} diff --git a/packages/client/runtime/src/client/sessions/pending.ts b/packages/client/runtime/src/client/sessions/pending.ts deleted file mode 100644 index b8b3492a04..0000000000 --- a/packages/client/runtime/src/client/sessions/pending.ts +++ /dev/null @@ -1,82 +0,0 @@ -// PendingWait: the carrier-protocol half of a pending host interaction. The runtime owns only -// envelope knowledge (rpcId backfill into a client-response); domain result encoding belongs to -// the interaction's consumer package. - -import type { - ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId, -} from '@deepseek-ai/dsh-api-remotes/client' - -/** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */ -export interface PendingPayloads { - approval: Omit, 'type' | 'sessionId'> - question: Omit, 'type' | 'sessionId'> -} - -/** Pending-interaction discriminant (the keys of PendingPayloads). */ -export type PendingKind = keyof PendingPayloads - -/** Session-list summary of the user action currently blocking progress. */ -export type PendingInteractionStatus = 'approval' | 'plan-review' | 'question' - -/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */ -export type PendingInteraction = { [K in PendingKind]: PendingWait }[PendingKind] - -/** Key prefixes, one per kind (the key doubles as the Session pending-map key). */ -const KEY_PREFIX: Record = { approval: 'a', question: 'q' } - -/** - * One pending host-owned interaction wait: an immutable render face - * (kind/key/sessionId/payload) plus the response carrier. respond() backfills - * the requested frame's rpcId into a client-response envelope — no consumer - * ever sees the raw rpcId. Settlement is expressed only by pending-list - * membership (the settled flag is a fail-loud guard, not a render input). - */ -export class PendingWait { - /** Interaction kind (union discriminant). */ - readonly kind: K - /** Opaque render identity, `:` — stable across baseline replay, usable as a React key. */ - readonly key: string - /** Owning session. */ - readonly sessionId: SessionId - /** The requested frame's domain fields, verbatim. */ - readonly payload: PendingPayloads[K] - #settled = false - readonly #rpcId: RpcId - readonly #respond: (message: ClientResponse) => Promise - - /** - * Minted by Session on a requested frame (public construction is the test-fixture path). - * @param kind - interaction kind. - * @param rpcId - the requested frame's stable envelope id (kept private; respond echoes it). - * @param sessionId - owning session. - * @param payload - the requested frame's domain fields. - * @param respond - the client-response carrier (api.respond). - */ - constructor( - kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K], - respond: (message: ClientResponse) => Promise, - ) { - this.kind = kind - this.key = `${KEY_PREFIX[kind]}:${rpcId}` - this.sessionId = sessionId - this.payload = payload - this.#rpcId = rpcId - this.#respond = respond - } - - /** - * Send a result for this wait: wraps it into the client-response envelope - * with the rpcId backfilled. Throws synchronously once settled. - * @param result - the result shell (ok value / error envelope), domain-encoded by the caller. - * @returns the carrier receipt. - */ - respond(result: ClientResponse['result']): Promise { - if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`) - return this.#respond({ type: 'client-response', rpcId: this.#rpcId, result }) - } - - /** Session-only settlement mark (the authoritative resolved frame arrived); respond() throws afterwards. */ - markSettled(): void { - this.#settled = true - } -} diff --git a/packages/client/runtime/src/client/sessions/provide.ts b/packages/client/runtime/src/client/sessions/provide.ts deleted file mode 100644 index 1ff3f1eaa3..0000000000 --- a/packages/client/runtime/src/client/sessions/provide.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * The session standard-props provide channel: provider roster, bundle - * materialization (fail-loud on undeclared/missing/duplicate members), the - * static no-session projection, and the atomic current-session projection - * observable. One implementation — SessionRuntime drives it from wire - * truth, the test runtime's sessions double drives it from fixtures — so - * the materialization rules and the projection semantics cannot drift - * between production and the test bench. - */ -import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionBinding, SessionProvideDescriptor } from './service.ts' - -/** The owner-side hooks: how the channel reaches the owner's live bundles and current selection. */ -export interface SessionProvideChannelHost { - /** - * Re-materialize every already-materialized bundle against the new roster - * (call {@link SessionProvideChannel.materializeInfo} per live binding). - * Lazily-materialized sessions pick the new roster up on first resolve. - */ - rebuildBundles(): void - /** Resolve the current selection's bundle (the owner's maybe-provide lookup). */ - resolveCurrent(): SessionMaybeProvideInfo -} - -/** - * Provider roster + materialization + current projection. The channel owns - * every rule a provider contribution must satisfy; owners keep only their - * per-session bundle storage and the definition of "current". - */ -export class SessionProvideChannel { - private readonly providers: SessionProvideDescriptor[] = [] - private maybeInfoCache: SessionMaybeProvideInfo - /** Latest published current bundle (identity comparison dedupes republish). */ - private currentSnapshot: SessionMaybeProvideInfo - /** Projection subscribers (plain cell: bundles hold live session sources, so no store freeze may touch them). */ - private readonly listeners = new Set<() => void>() - - /** - * Atomic current-session provide projection: selection changes and - * provider-roster changes publish through this one source, so a roster - * change under a stable current id republishes the bundle instead of - * stranding mounted entries. - */ - readonly currentProvideInfo: HostObservable - - /** - * @param host - owner-side bundle storage and current-selection resolution. - */ - constructor(private readonly host: SessionProvideChannelHost) { - // The runtime's own contribution comes first: useSession rides the same - // provide channel every plugin uses (no renderer special case). - this.providers.push({ - hooks: ['session'], - resolve: binding => ({ hooks: { session: binding.session } }), - }) - this.maybeInfoCache = this.materializeMaybeInfo() - this.currentSnapshot = this.maybeInfoCache - this.currentProvideInfo = { - getSnapshot: () => this.currentSnapshot, - subscribe: (fn) => { - this.listeners.add(fn) - return () => { this.listeners.delete(fn) } - }, - } - } - - /** The static no-session projection under the current roster (declared names present, values undefined). */ - get maybeInfo(): SessionMaybeProvideInfo { - return this.maybeInfoCache - } - - /** - * Register a per-session standard-props provider (see - * SessionRuntime.provide for the product contract). Live bundles rebuild - * immediately; misdeclared providers fail loud here, at the registration - * edge, and the registration rolls back — the channel never stays on a - * roster it cannot materialize. - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider. - */ - provide(descriptor: SessionProvideDescriptor): () => void { - this.providers.push(descriptor) - try { - this.applyRosterChange() - } catch (error) { - this.providers.splice(this.providers.indexOf(descriptor), 1) - // Restore the previous (valid) roster's bundles; cannot rethrow — the - // pre-push roster materialized successfully before. - this.applyRosterChange() - throw error - } - return () => { - const at = this.providers.indexOf(descriptor) - if (at >= 0) this.providers.splice(at, 1) - this.applyRosterChange() - } - } - - /** - * Re-derive the current selection's bundle and publish it when it changed. - * Bundles are identity-stable per (scope, roster) materialization, so an - * identity compare is exact; synchronous notify — call sites (the owner's - * list subscription, provide()) already sit behind their own batching or - * registration edges. - */ - publishCurrent(): void { - const next = this.host.resolveCurrent() - if (next === this.currentSnapshot) return - this.currentSnapshot = next - for (const fn of [...this.listeners]) { - try { - fn() - } catch (error) { - // Contain subscriber failures: this notify runs inside the list - // notification, where a throwing render-side subscriber would starve - // later listeners and abort the projection pass that scheduled it. - console.error('sessions.currentProvideInfo subscriber failed:', error) - } - } - } - - /** - * Materialize the standard-props bundle for one session (fails loud on - * undeclared, missing, and duplicate member names). - * @param binding - session assembly handle fed to every resolver. - * @returns the materialized bundle (identity-stable until the next materialization). - */ - materializeInfo(binding: SessionBinding): SessionProvideInfo { - const hooks: Record> = {} - const props: Record = {} - for (const descriptor of this.providers) { - const contribution = descriptor.resolve(binding) - const contributedHooks = contribution.hooks ?? {} - const contributedProps = contribution.props ?? {} - for (const name of Object.keys(contributedHooks)) { - if (!(descriptor.hooks ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared hook "${name}"`) - } - } - for (const name of Object.keys(contributedProps)) { - if (!(descriptor.props ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared prop "${name}"`) - } - } - for (const name of descriptor.hooks ?? []) { - const source = contributedHooks[name] - if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`) - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = source - } - for (const name of descriptor.props ?? []) { - if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`) - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = contributedProps[name] - } - } - return { - sessionId: binding.sessionId, - hooks, - props, - // The useProjection seat: key-addressed bare value faces off the - // session's projection store (open key space — never a static roster member). - projections: { faceOf: key => binding.session.projections.faceOf(key) }, - } - } - - /** Rebuild the static projection and the owner's live bundles, then republish the current one. */ - private applyRosterChange(): void { - this.maybeInfoCache = this.materializeMaybeInfo() - this.host.rebuildBundles() - this.publishCurrent() - } - - /** Build the static no-session kit and reject duplicate declared names. */ - private materializeMaybeInfo(): SessionMaybeProvideInfo { - const hooks: Record = {} - const props: Record = {} - for (const descriptor of this.providers) { - for (const name of descriptor.hooks ?? []) { - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = undefined - } - for (const name of descriptor.props ?? []) { - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = undefined - } - } - return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session - } -} diff --git a/packages/client/runtime/src/client/sessions/remotes.ts b/packages/client/runtime/src/client/sessions/remotes.ts deleted file mode 100644 index 4fa503345b..0000000000 --- a/packages/client/runtime/src/client/sessions/remotes.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Remote namespaces the Session cluster calls. One parameter for one concept: - * the generated surface a Session and its manager reach the Host through. - * - * @module @deepseek-ai/dsh-client-runtime/client/sessions/remotes - */ - -import type { Context } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-api-remotes/client' - -/** The generated Remote namespaces a Session and its manager call. */ -export type SessionRemotes = Pick diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts deleted file mode 100644 index 02939cf28f..0000000000 --- a/packages/client/runtime/src/client/sessions/session.ts +++ /dev/null @@ -1,809 +0,0 @@ -// Sessions remain resident after creation so they continue consuming mux frames off-screen. - -import type { Context } from '@deepseek-ai/cordis' -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { - HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError, - RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, -} from '@deepseek-ai/dsh-api-remotes/client' -// Value import from the inline-safe wire layer (not the connection plugin): -// plugin-to-plugin value imports are a bundle purity error. -import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { SessionFace } from '../contract/session.ts' -import { ConversationNodeAssembler } from './conversation-assembler.ts' -import type { ConversationRuntime } from './conversation-assembler.ts' -import type { ConversationEventInput, ConversationPublication } from '../contract/conversation.ts' -import type { - ChatSnapshot, ComposerPhase, ConversationSnapshot, OpenState, PromptError, -} from './conversation.ts' -import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts' -import type { PendingInteraction } from './pending.ts' -import { PendingWait } from './pending.ts' -import { Notifier } from './notifier.ts' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import type { SessionRemotes } from './remotes.ts' -import { ProjectionValueStore } from './projection-store.ts' -import type { ProjectionsBaseline } from './projection-store.ts' -import { resolvedClientTimeZone } from '../time-zone.ts' -import { SessionQueueMirror } from './queue-mirror.ts' - -/** Messages requested per history page. */ -export const PAGE_MESSAGES = 50 - -/** Manager-owned observers of a Session object's local state edges. */ -export interface SessionOptions { - /** Catalog-discovered address selecting non-activating subagent transport. */ - address?: SubagentAddress - /** Whether the exact direct parent Agent was live at the latest catalog read. */ - parentAvailable?: boolean - /** - * First ACCEPTED prompt on a blank session (fires at most once, on the - * prompt RPC's success response): the manager mirrors the blank→false flip - * into its list row so the session surfaces without waiting for a host - * frame. Acceptance is the flip point because it proves the user message - * is in the host log; a rejected first prompt keeps the session blank - * (hidden, still reusable by connectWorkspace). - */ - onEngaged?(session: Session): void - /** - * Manager-owned projection value store to adopt (frames route through the - * manager and values outlive instantiation); omitted, the Session owns a - * private store (bare object-layer construction). - */ - projections?: ProjectionValueStore - /** Runtime registries used by this Session-owned Conversation assembler. */ - conversation?: ConversationRuntime -} - -/** - * Owns a session's event window, derived conversation state, and observable - * snapshot. React bindings remain outside this data layer. Features see only - * the {@link SessionFace} slice (ISession verbs + the snapshot source); the - * remaining public members are manager/runtime entry points. - */ -export class Session implements SessionFace { - // ---- Window and derived state (all private; the snapshot is the only read API) ---- - private events: SessionEvent[] = [] - /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view). - * Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */ - private views: (ToolEventView | undefined)[] = [] - private baseSeq = 0 - private hasMore = false - private openState: OpenState = 'cold' - private openError: RpcError | null = null - private openPromise: Promise | null = null - /** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt - * a pre-disconnect open whose history request is already doomed. Stale doOpen - * passes drop all writes once the generation moves on. */ - private openGeneration = 0 - private loadingOlder = false - private pending = new Map() - private pendingRev = 0 - private pendingCache: { rev: number; value: PendingInteraction[] } | null = null - /** Authoritative stream-only inbox snapshot; pending work never hits history. */ - private readonly queueMirror = new SessionQueueMirror() - /** Session-owned business Context engine over the contiguous raw window. */ - private readonly conversation: ConversationNodeAssembler - private running = false - private address: SubagentAddress | undefined - private parentAvailable = false - /** - * Sticky send marker, private input of the composerPhase derivation: set - * synchronously before prompt()'s first await, never reset — the blank → - * engaging edge of the phase machine (see ComposerPhase). - */ - private promptAttempted = false - /** A first accepted prompt stays in the engaging phase until its turn is observable. */ - private firstPromptPendingTurn = false - /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */ - private blankBit = true - private removed = false - private promptError: PromptError | null = null - private lastAgentError: string | null = null - /** Live events buffered during open/resync and stitched by sequence once history lands. */ - private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] - /** Gap repair in flight; live events detour to the buffer until the tail page lands. */ - private stitching = false - /** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */ - private subscribedLastSeq: number | null = null - - /** - * Per-session projection value store (push model; see the session-projection - * subsystem page, docs/subsystems/session-projection.md): finished whole - * values computed on the host, seeded by the tail page's - * projections block and updated by `session/projection` frames under the - * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)` - * (the useProjection resolution face); the conversation snapshot never - * carries projection values, and no client-side domain folding exists. - * Manager-owned when constructed through SessionManager (frames route and - * the store outlives instantiation, the title-snapshot precedent); a bare - * construction gets a private store. - */ - readonly projections: ProjectionValueStore - - private snapshotCache: ConversationSnapshot - private readonly notifier: Notifier - /** - * Agent-scoped cordis context, bound once by SessionRuntime when it - * mints the scope (the client mirror of the host Agent's loopCtx). The - * Session dispatches its own scoped events through it; undefined means - * unbound (bare object-layer construction) or already pruned — both skip - * dispatch-dependent behavior rather than fail. - */ - private actx: Context | undefined - - /** - * @param sessionId - Host session identity (client sessions are always Host-born). - * @param api - shared wire client. - * @param remote - generated Remote namespaces this session calls. - * @param options - optional manager-owned state observers. - */ - constructor( - readonly sessionId: SessionId, - private readonly api: IApiClient, - private readonly remote: SessionRemotes, - private readonly options: SessionOptions = {}, - ) { - this.projections = options.projections ?? new ProjectionValueStore() - this.address = options.address - this.parentAvailable = options.parentAvailable ?? false - this.conversation = options.conversation === undefined - ? new ConversationNodeAssembler( - { entries: () => [], fallbackEntry: () => undefined }, - { entries: () => [] }, - ) - : new ConversationNodeAssembler(options.conversation.events, options.conversation.views) - this.notifier = new Notifier(() => { - this.conversation.flush() - this.snapshotCache = this.buildSnapshot() - }) - this.snapshotCache = this.buildSnapshot() - } - - /** - * Bind the Agent-scoped context minted by SessionRuntime (single write; - * a second bind is a wiring error and throws). Direction stays one-way at - * this binding boundary: consumers still reach the Session via `sessions.sessionOf`, - * while the Session holds its own dispatch point (host Agent.loopCtx - * mirror). - * @param actx - the agent's scoped context. - */ - bindScope(actx: Context): void { - if (this.actx !== undefined) throw new Error(`session ${this.sessionId} already has a bound scope`) - this.actx = actx - } - - /** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */ - unbindScope(): void { - this.actx = undefined - } - - // ---- Operations ---- - - /** - * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. - * @param content - text plus browser-owned temporary image uploads. - * @param mode - queue appends after the current turn; steer interrupts it. - * @returns the prompt result (also mirrored into promptError on failure). - */ - async prompt( - content: PromptContentPart[], - mode: 'queue' | 'steer', - signal?: AbortSignal, - ): Promise> { - this.promptError = null - this.lastAgentError = null - // Synchronous, before the first await: the blank → engaging edge must be - // visible on the session area's very first frame when a caller sends - // ahead of navigation (first-send flow). - this.promptAttempted = true - if (this.blankBit) this.firstPromptPendingTurn = true - this.notifier.markDirty() - let result: RpcResult<{ accepted: true }> - try { - if (this.address === undefined) { - result = (await this.api.sessions.prompt({ - sessionId: this.sessionId, - mode, - content, - clientTimeZone: resolvedClientTimeZone(), - }, signal)).result - } else if (this.address.mode === 'one-shot') { - result = { - ok: false, - error: { - code: 'subagent-not-resumable', - message: 'one-shot subagent conversations are read-only', - details: { childSessionId: this.address.childSessionId }, - }, - } - } else { - if (content.some(part => part.type === 'image')) { - result = { - ok: false, - error: { - code: 'attachment-error', - message: 'Image input is unavailable for subagent continuations.', - details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, - }, - } - } else { - const routed = (await this.api.subagents.prompt({ - ...this.address, - content: content.flatMap(part => part.type === 'text' - ? [{ type: 'text' as const, text: part.text }] - : []), - clientTimeZone: resolvedClientTimeZone(), - }, signal)).result - result = routed.ok ? { ok: true, value: { accepted: true } } : routed - } - } - } catch (error) { - result = transportError(error) - } - if (!result.ok) { - this.promptError = { op: 'send', error: result.error } - this.notifier.markDirty() - return result - } - // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the - // conversation's first turn on the host (the host criterion — a logged - // turn/start — is fact, not optimism; standalone command and projection - // events never flip it), while a rejected first prompt must keep the - // session blank — the client-side blank mirror only ever lowers, so - // flipping early on a failure would surface the session forever and - // strip its connectWorkspace reuse eligibility against the host's - // authority. - if (this.blankBit) { - this.blankBit = false - this.options.onEngaged?.(this) - this.notifier.markDirty() - } - return result - } - - /** - * Resolve one image referenced by this session into browser-consumable bytes. - * @param attachmentId - opaque id found in the folded session log. - * @returns the authenticated reference and decoded bytes. - */ - async readAttachment( - attachmentId: AttachmentIdType, - ): Promise> { - try { - const result = (await this.api.sessions.attachment({ - sessionId: this.sessionId, - attachmentId, - })).result - if (!result.ok) return result - const binary = atob(result.value.data) - const data = Uint8Array.from(binary, char => char.charCodeAt(0)) - return { ok: true, value: { attachment: result.value.attachment, data } } - } catch (error) { - return transportError(error) - } - } - - /** Apply one operation to a still-pending queue occurrence. */ - async updateQueue(itemId: MessageId, action: QueueAction): Promise> { - try { - return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result - } catch (error) { - return transportError(error) - } - } - - /** - * Stop the active turn while the Host preserves pending inbox work; failures - * land in promptError (same error-strip display slot). A continuable - * subagent address routes through `subagent.interrupt`, whose durable - * parent-address authority works without a live parent Agent; a one-shot - * address stays uncancellable (the UI offers no stop action, so this arm is - * defensive). - * @returns the cancel result. - */ - async cancel(): Promise> { - const address = this.address - if (address !== undefined && address.mode === 'one-shot') { - const result: RpcResult<{ accepted: true }> = { - ok: false, - error: { - code: 'subagent-delivery-unavailable', - message: 'subagent activation cancellation is unavailable', - details: { childSessionId: address.childSessionId }, - }, - } - this.promptError = { op: 'stop', error: result.error } - this.notifier.markDirty() - return result - } - let result: RpcResult<{ accepted: true }> - try { - result = address !== undefined - ? (await this.api.subagents.interrupt(address)).result - : (await this.api.sessions.cancel({ sessionId: this.sessionId })).result - } catch (error) { - result = transportError(error) - } - if (!result.ok) { - this.promptError = { op: 'stop', error: result.error } - this.notifier.markDirty() - } - return result - } - - /** - * Rename: contract session.rename 1:1. On success settle the 'title' - * projection cell from the response's `{title, seq}` under the store's - * higher-seq-wins rule (the push frame arriving later is a no-op replay), - * so the list row and any useProjection('title') reader update without - * waiting for the mux frame. - * @param title - raw title text (the host normalizes acceptance). - * @returns the rename result (normalized accepted title + title event seq). - */ - async rename(title: string): Promise> { - try { - const { result } = await this.api.sessions.rename({ sessionId: this.sessionId, title }) - if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) - return result - } catch (error) { - return transportError(error) - } - } - - /** - * Execute one slash-command line against this session's agent — pure - * admission semantics (the host executor durably logs the lifecycle; - * outcomes render as flow nodes, never as a response echo). - * @param line - the full command line, leading slash included. - * @returns the admission result, or the error branch on transport failure. - */ - async command(line: string): Promise> { - const result = await this.remote.commands.execute(this.sessionId, line, []) - if (!result.ok) return result - return { ok: true, value: { matched: result.value !== undefined } } - } - - /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ - open(): Promise { - if (this.openState === 'open') return Promise.resolve() - if (this.openPromise !== null) return this.openPromise - const promise = this.doOpen(this.openGeneration).finally(() => { - // Identity-guarded: a superseded open must not null out the promise resync just started. - if (this.openPromise === promise) this.openPromise = null - }) - this.openPromise = promise - return promise - } - - /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */ - async loadOlder(): Promise { - if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return - this.loadingOlder = true - this.notifier.markDirty() - try { - const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES }) - if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded) - const older = result.value.events - if (older.length === 0) { - this.hasMore = result.value.hasMore - this.conversation.prepend([], this.hasMore) - return - } - const tail = older[older.length - 1] - if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { - // Continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream. - console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`) - this.hasMore = false - this.conversation.prepend([], false) - return - } - this.events = [...older.map(e => e.event), ...this.events] - this.views = [...older.map(e => e.view), ...this.views] - /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ - this.baseSeq = older[0]?.event.seq ?? this.baseSeq - this.hasMore = result.value.hasMore - this.conversation.prepend(older.map(conversationInput), this.hasMore) - } catch (error) { - console.error('[web-runtime] loadOlder failed:', error) - } finally { - this.loadingOlder = false - this.notifier.markDirty() - } - } - - /** Reconnect rebuild (manager calls this on onConnected for instances that were opened): - * reset the window and rerun open; pending waits for the baseline replay. Invalidates any - * in-flight open first — its history request rode the dead connection and must not settle - * the fresh generation into 'error'. */ - async resync(): Promise { - // The queue mirror is NOT cleared here: onConnected (which drives resync) - // races the mux frames — the fresh generation's baseline may have landed - // already, and the host never resends it. The mirror re-baselines on the - // session/subscribed frame instead (same stream as the queue snapshot - // that follows it, so ordering is guaranteed). - if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) - this.openGeneration++ - this.openPromise = null - this.openState = 'cold' - this.openError = null - this.events = [] - this.views = [] - this.baseSeq = 0 - // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim - // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. - this.pending.clear() - this.pendingRev++ - this.subscribedLastSeq = null - this.liveBuffer = [] - this.notifier.markDirty() - await this.open() - } - - // ---- Subscription API (useSyncExternalStore direct wiring) ---- - - /** - * uSES subscription entry. - * @param listener - change callback. - * @returns the unsubscribe function. - */ - subscribe(listener: () => void): () => void { - return this.notifier.subscribe(listener) - } - - /** - * Cached conversation snapshot (rebuilt lazily when dirty with no listeners). - * @returns the cached reference (stable until the next flush). - */ - getSnapshot(): ConversationSnapshot { - this.notifier.ensureFresh() - return this.snapshotCache - } - - // ---- Manager-only entry points (@internal; never called by the UI) ---- - - /** - * Mux frame arrival (the dispatch switch). - * @param rpcId - the frame envelope id (the respond backfill key for requested frames). - * @param frame - the routed frame. - */ - handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void { - switch (frame.type) { - case 'session/event': { - this.acceptLiveEvent(frame.event, frame.view) - return - } - case 'session/queue': { - this.queueMirror.replace(frame.items) - this.notifier.markDirty() - return - } - case 'session/subscribed': { - this.subscribedLastSeq = frame.lastSeq - // New mux-generation baseline: the host pushes this session's queue - // snapshot AFTER the subscribed frame on the same stream, so the - // stale mirror clears here — race-free against onConnected/resync - // timing (clearing there could wipe a baseline that already landed). - if (this.queueMirror.reset()) this.notifier.markDirty() - return - } - case 'approval/requested': { - const { type: _type, sessionId: _sid, ...payload } = frame - this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m))) - this.notifier.markDirty() - return - } - case 'approval/resolved': { - for (const item of this.pending.values()) { - if (item.kind === 'approval' && item.payload.approvalId === frame.approvalId) this.settle(item) - } - this.notifier.markDirty() - return - } - case 'question/requested': { - const { type: _type, sessionId: _sid, ...payload } = frame - this.mint(new PendingWait('question', rpcId, this.sessionId, payload, m => this.api.respond(m))) - this.notifier.markDirty() - return - } - case 'question/resolved': { - const item = this.pending.get(`q:${frame.questionRpcId}`) - if (item !== undefined) this.settle(item) - this.notifier.markDirty() - return - } - default: - return // stream/error never reaches Session (Controller converges it); unknown frames ignored (documented default) - } - } - - /** - * Running-bit relay from the host stream (list entry and snapshot stay consistent). - * @param running - the new running state. - */ - handleRunning(running: boolean): void { - // Turn-start conversion: a blank session never runs, so the first - // running:true proves another side's first message landed. - if (running && this.blankBit) { - this.blankBit = false - this.notifier.markDirty() - } - if (running) this.firstPromptPendingTurn = false - if (this.running === running) return - this.running = running - this.notifier.markDirty() - } - - /** - * Install or clear the catalog-discovered transport address. A changed - * address rebuilds an already-open window through its new history route. - * @param address - direct parent/child address, or undefined for ordinary transport. - * @param parentAvailable - latest exact-parent availability hint. - */ - configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void { - const same = this.address?.parentSessionId === address?.parentSessionId - && this.address?.childSessionId === address?.childSessionId - && this.address?.mode === address?.mode - this.address = address - this.parentAvailable = parentAvailable - if (!same && this.openState !== 'cold') void this.resync() - else this.notifier.markDirty() - } - - /** - * Update only the parent availability hint from a catalog refresh. - * @param available - whether the exact direct parent is live. - */ - handleSubagentParentAvailable(available: boolean): void { - if (this.parentAvailable === available) return - this.parentAvailable = available - this.notifier.markDirty() - } - - /** - * Blank-bit relay from the authoritative summary source (list baseline and - * the session-added frame). Monotone: once any signal (local first send, - * running flip, an earlier summary) cleared it, a stale true never - * re-blanks. - * @param blank - the summary's derived empty-log bit. - */ - handleBlank(blank: boolean): void { - if (blank === this.blankBit) return - if (blank && (this.promptAttempted || this.running)) return - this.blankBit = blank - this.notifier.markDirty() - } - - /** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */ - handleRemoved(): void { - this.removed = true - this.notifier.markDirty() - } - - /** - * host/agent-error relay: the only outlet for live failures with no turn position. - * @param message - the stringified error. - */ - handleAgentError(message: string): void { - this.lastAgentError = message - this.notifier.markDirty() - } - - /** No-op because session instances remain resident. */ - dispose(): void {} - - /** Rebuild the current window after a low-frequency Definition or view registration change. */ - rebuildConversationRegistry(): void { - this.scheduleConversation(this.conversation.rebuildRegistry()) - } - - // ---- Private ---- - - /** Requested-frame arrival: the wait enters the pending map under its own key. */ - private mint(wait: PendingInteraction): void { - this.pending.set(wait.key, wait) - this.pendingRev++ - } - - /** Authoritative resolved-frame settlement: mark, then drop from the pending map. */ - private settle(wait: PendingInteraction): void { - wait.markSettled() - this.pending.delete(wait.key) - this.pendingRev++ - } - - /** @param generation - openGeneration at launch; every await re-checks it and a stale pass - * drops all writes (resync superseded this open — its outcome belongs to a dead connection). */ - private async doOpen(generation: number): Promise { - this.openState = 'loading' - this.openError = null - this.notifier.markDirty() - try { - let { result } = await this.history({ maxMessages: PAGE_MESSAGES }) - if (generation !== this.openGeneration) return - if (!result.ok) { - this.openState = 'error' - this.openError = result.error - return - } - this.installWindow(result.value.events, result.value.hasMore, result.value.projections) - // Gap detection: baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. - const tailSeq = this.windowTailSeq() - if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) { - result = (await this.history({ maxMessages: PAGE_MESSAGES })).result - if (generation !== this.openGeneration) return - if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections) - } - this.openState = 'open' - } catch (error) { - if (generation !== this.openGeneration) return - this.openState = 'error' - const folded = transportError(error) - /* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */ - this.openError = folded.ok ? null : folded.error - } finally { - if (generation === this.openGeneration) this.notifier.markDirty() - } - } - - /** Install the history window + stitch the liveBuffer (seq is the sole dedup key). - * Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here - * (doOpen flips it after install), so recursing would push every buffered event straight - * back into liveBuffer where nothing ever drains it — a silent drop loop. - * A carried projections block seeds the value store (higher seq wins, so a stale - * baseline cannot overwrite a newer push frame); the window events themselves are - * never folded — the host is the only computation site. */ - private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { - this.events = entries.map(e => e.event) - this.views = entries.map(e => e.view) - this.baseSeq = this.events[0]?.seq ?? 0 - this.hasMore = hasMore - if (this.events.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false - this.conversation.replaceWindow(entries.map(conversationInput), hasMore) - if (projections !== undefined) this.projections.seed(projections) - const buffered = this.liveBuffer - this.liveBuffer = [] - for (const item of buffered) this.appendLive(item.event, item.view) - this.notifier.markDirty() - } - - /** Seq-guarded append shared by stitching and the open-state live path. */ - private appendLive(event: SessionEvent, view?: ToolEventView): ConversationPublication { - const tailSeq = this.windowTailSeq() - if (tailSeq !== null && event.seq <= tailSeq) return 'none' // replay overlap, drop - this.events.push(event) - this.views.push(view) - if (event.type === 'turn/start') this.firstPromptPendingTurn = false - const queueChanged = this.queueMirror.acceptDurable(event) - const publication = this.conversation.append({ event, view }) - return queueChanged ? 'immediate' : publication - } - - /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop; - * a seq gap -> buffer + tail-page repull instead of appending a hole (a gap is an - * expected reconnect-window artifact, repaired by refetch). The window stays one contiguous - * raw range, which lets Conversation Definitions correlate every recorded event between its - * ends and lets a compaction checkpoint resolve its cited summary event. */ - private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void { - if (this.openState === 'loading' || this.stitching) { - this.liveBuffer.push({ event, view }) - return - } - if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open) - const tailSeq = this.windowTailSeq() - if (tailSeq !== null && event.seq > tailSeq + 1) { - this.liveBuffer.push({ event, view }) - void this.repairGap() - return - } - this.scheduleConversation(this.appendLive(event, view)) - } - - /** Route assembler cadence into the Session's existing microtask/RAF notifier. */ - private scheduleConversation(publication: ConversationPublication): void { - if (publication === 'immediate') this.notifier.markDirty() - else if (publication === 'animation-frame') this.notifier.markFrameDirty() - } - - /** Resync-lite: repull the tail page and stitch the liveBuffer through the shared - * installWindow path. No openState transition — the UI keeps the current window (no loading - * flash); events arriving meanwhile detour to liveBuffer via the stitching flag. */ - private async repairGap(): Promise { - /* v8 ignore next -- re-entry guard: acceptLiveEvent already detours to liveBuffer while stitching, so no second call reaches here. */ - if (this.stitching) return - this.stitching = true - const generation = this.openGeneration - try { - const { result } = await this.history({ maxMessages: PAGE_MESSAGES }) - // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself. - if (result.ok && generation === this.openGeneration && this.openState === 'open') { - this.installWindow(result.value.events, result.value.hasMore, result.value.projections) - } - } catch (error) { - console.error('[web-runtime] gap repair failed:', error) - } finally { - this.stitching = false - } - } - - private windowTailSeq(): number | null { - const tail = this.events[this.events.length - 1] - return tail === undefined ? null : tail.seq - } - - private buildSnapshot(): ConversationSnapshot { - if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { - this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } - } - const chat = (this.conversation.snapshot('chat') as ChatSnapshot | undefined) ?? EMPTY_CHAT_SNAPSHOT - const legacy = chat.legacy - return { - sessionId: this.sessionId, - views: this.conversation, - chat, - nodes: legacy.nodes, - turnTimings: legacy.turnTimings, - turnEnds: legacy.turnEnds, - partial: legacy.partial, - runningCalls: legacy.runningCalls, - pending: this.pendingCache.value, - queue: this.queueMirror.snapshot(), - running: this.running, - subagent: this.address === undefined - ? null - : { address: this.address, parentAvailable: this.parentAvailable }, - composerPhase: derivePhase( - hasVisibleConversationContent(chat) - || (!this.blankBit && !this.firstPromptPendingTurn) - || this.running - || this.pendingCache.value.length > 0, - this.promptAttempted, - ), - removed: this.removed, - openState: this.openState, - openError: this.openError, - hasMore: this.hasMore, - loadingOlder: this.loadingOlder, - promptError: this.promptError, - blank: this.blankBit, - lastAgentError: this.lastAgentError, - } - } - - /** Select ordinary or addressed history transport from the stored browser fact. */ - private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise> { - return this.address === undefined - ? this.api.sessions.history({ sessionId: this.sessionId, ...payload }) - : this.api.subagents.history({ ...this.address, ...payload }) - } -} - -/** Convert one wire history row into the assembler's transport-neutral input. */ -function conversationInput(entry: HistoryEntry): ConversationEventInput { - return { event: entry.event, view: entry.view } -} - -/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */ -function hasVisibleConversationContent(chat: ChatSnapshot): boolean { - return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command') -} - -/** - * The composerPhase judgment — the single site that knows the predicate - * (consumers switch on the result, never re-derive). A failed first prompt - * stays engaging until an authoritative accepted-turn, running, or pending - * signal arrives (retry semantics — see ComposerPhase). - * @param hasContent - authoritative non-blank activity beyond a pending first - * prompt, visible non-command Chat content, a running turn, or a pending interaction. - * @param promptAttempted - a prompt was initiated on this session object. - * @returns the derived phase. - */ -function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPhase { - if (hasContent) return 'active' - return promptAttempted ? 'engaging' : 'blank' -} diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts deleted file mode 100644 index 9b54dbb429..0000000000 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ /dev/null @@ -1,425 +0,0 @@ -/** Workspace baseline, incremental-frame, and unary-action owner. */ - -import type { - HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-api-remotes/client' -import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' -import { Notifier } from '../sessions/notifier.ts' -import { Workspace, type WorkspaceCreateInput } from './workspace.ts' - -/** Monotone workspace-list arrival lifecycle. */ -export type WorkspaceListPhase = 'pending' | 'ready' - -/** Immutable workspace-list snapshot. */ -export interface WorkspaceListSnapshot { - items: readonly WorkspaceView[] - /** - * Registry-global archive set in Host order (hidden from grouping - * surfaces; accounting slots retained). A plain array, not a Set: public - * snapshot state stays in the store engine's plain-data vocabulary - * (immer drafts reject Sets without the MapSet plugin); membership - * lookups build their own transient Set where they need one. - */ - archivedSessionIds: readonly SessionId[] - state: 'idle' | 'loading' | 'error' - phase: WorkspaceListPhase - error: RpcError | null -} - -type WorkspaceDelta = - | { type: 'upsert'; workspace: WorkspaceView } - | { type: 'remove'; workspaceId: WorkspaceId } - | { type: 'order'; workspaceIds: readonly WorkspaceId[] } - -/** Workspace object cluster driven by one list baseline and changed-frame upserts. */ -export class WorkspaceManager { - private items: Workspace[] = [] - private itemViewsSource: readonly Workspace[] | null = null - private itemViewsCache: readonly WorkspaceView[] = [] - // Full-snapshot state (list response / unary response / changed frame all - // carry the complete set), so deltas never merge — installs replace. - private archivedSessionIds: readonly SessionId[] = [] - private state: WorkspaceListSnapshot['state'] = 'idle' - private phase: WorkspaceListPhase = 'pending' - private error: RpcError | null = null - private inflight: Promise | null = null - private refreshFrames: WorkspaceDelta[] | null = null - /** - * True once a frame or unary echo installed the archive set while a list - * request was in flight: that install is newer than the pending baseline, - * so the baseline's (older) set must not roll it back — the archive - * mirror of replaying refreshFrames over the item baseline. - */ - private archivedSupersedesRefresh = false - /** Latest local reorder request; only its unary echo may install order. */ - private orderRequestGeneration = 0 - /** Increments on order frames so a later remote commit outranks an older unary echo. */ - private orderFrameGeneration = 0 - /** Last complete order accepted from a Host baseline, frame, or current unary echo. */ - private committedOrder: WorkspaceId[] = [] - /** - * Ids this process has seen removed, kept for the connection's lifetime so - * a late changed frame or a stale baseline row cannot resurrect a deleted - * row. Correctness rests on Host ids never being reused (the registry mints - * a fresh `randomUUID` per record, including when the same directory is - * registered again) — a path-derived id scheme would turn these entries - * into permanent blindfolds and must clear them instead. - */ - private readonly removedIds = new Set() - private snapshotCache: WorkspaceListSnapshot - private readonly notifier = new Notifier(() => { - this.snapshotCache = this.buildSnapshot() - }) - - /** @param api - shared wire client. */ - constructor(private readonly api: IApiClient) { - this.snapshotCache = this.buildSnapshot() - } - - /** - * Refresh from workspace.list. The first successful response establishes - * Host order; later responses re-establish the durable order so reconnects - * adopt reorders committed while this client was offline. Frames arriving - * during the RPC are replayed over its response. - * @returns the shared in-flight refresh. - */ - refresh(): Promise { - if (this.inflight !== null) return this.inflight - this.state = 'loading' - this.error = null - const frames: WorkspaceDelta[] = [] - this.refreshFrames = frames - this.notifier.markDirty() - this.inflight = (async () => { - try { - const { result } = await this.api.workspace.list({}) - if (result.ok) { - let items = result.value.items - items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) - for (const delta of frames) items = applyWorkspaceDelta(items, delta) - this.installViews(items) - if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds) - this.state = 'idle' - this.phase = 'ready' - } else { - this.state = 'error' - this.error = result.error - } - } catch (error) { - this.state = 'error' - const folded = transportError(error) - /* v8 ignore next -- transportError always returns the failure branch. */ - this.error = folded.ok ? null : folded.error - } finally { - this.refreshFrames = null - this.archivedSupersedesRefresh = false - this.inflight = null - this.notifier.markDirty() - } - })() - return this.inflight - } - - /** - * Create or resolve a real Workspace, then publish its returned snapshot - * without waiting for the changed frame. - * @param input - the existing absolute path to adopt. - * @returns the wire result. - */ - async create(input: WorkspaceCreateInput): Promise> { - const workspace = new Workspace(this.api, input) - const completion = workspace.materialize() - if (completion === undefined) throw new Error('a local Workspace must be materializable') - const result = await completion - if (result.ok) this.upsert(result.value.workspace, workspace) - return result - } - - /** - * Rename a Workspace, then publish its returned snapshot without waiting - * for the changed frame. - * @param workspaceId - target workspace. - * @param title - new display title. - * @returns the wire result. - */ - async rename(workspaceId: WorkspaceId, title: string): Promise> { - const { result } = await this.api.workspace.rename({ workspaceId, title }) - if (result.ok) this.upsert(result.value.workspace) - return result - } - - /** - * Delete a Workspace registration and remove its local projection from the - * unary response without waiting for the Host frame. - * @param workspaceId - target workspace. - * @returns the wire result. - */ - async delete(workspaceId: WorkspaceId): Promise> { - const { result } = await this.api.workspace.delete({ workspaceId }) - if (result.ok) this.remove(workspaceId, true) - return result - } - - /** - * Move a Workspace within the registry display order and install the full - * returned order without waiting for the Host frame. - * @param workspaceId - Workspace to move. - * @param beforeWorkspaceId - Anchor workspace; omitted appends. - * @returns the wire result. - */ - async insertBefore( - workspaceId: WorkspaceId, - beforeWorkspaceId?: WorkspaceId, - ): Promise> { - const requestGeneration = ++this.orderRequestGeneration - const frameGeneration = this.orderFrameGeneration - const localOrder = this.itemViews().map(workspace => workspace.workspaceId) - this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId)) - let result: RpcResult<{ workspaceIds: WorkspaceId[] }> - try { - ;({ result } = await this.api.workspace.insertBefore({ - workspaceId, - ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, - })) - } catch (error) { - if (requestGeneration === this.orderRequestGeneration - && frameGeneration === this.orderFrameGeneration) { - this.installOrder(this.committedOrder) - } - throw error - } - if (result.ok && requestGeneration === this.orderRequestGeneration - && frameGeneration === this.orderFrameGeneration) { - this.installOrder(result.value.workspaceIds, true) - } else if (!result.ok && requestGeneration === this.orderRequestGeneration - && frameGeneration === this.orderFrameGeneration) { - this.installOrder(this.committedOrder) - } - return result - } - - /** - * Move a session within its Workspace's manual order, then publish the - * returned snapshot without waiting for the changed frame. - * @param workspaceId - owning workspace. - * @param sessionId - accounted session to move. - * @param beforeSessionId - accounted anchor to insert before; omitted appends. - * @returns the wire result. - */ - async insertSessionBefore( - workspaceId: WorkspaceId, - sessionId: SessionId, - beforeSessionId?: SessionId, - ): Promise> { - const { result } = await this.api.workspace.insertSessionBefore({ - workspaceId, sessionId, - ...beforeSessionId === undefined ? {} : { beforeSessionId }, - }) - if (result.ok) this.upsert(result.value.workspace) - return result - } - - /** - * Archive one session in the registry-global set, then install the - * returned full set without waiting for the changed frame. - * @param sessionId - session to archive. - * @returns the wire result. - */ - async archiveSession(sessionId: SessionId): Promise> { - const { result } = await this.api.workspace.archiveSession({ sessionId }) - if (result.ok) this.installArchived(result.value.archivedSessionIds) - return result - } - - /** - * Host-frame entry. Non-workspace frames are ignored so the runtime can - * fan one host stream out to both object managers. - * @param envelope - host stream envelope. - */ - handleHostEnvelope(envelope: RpcRequest): void { - if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) - else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) - else if (envelope.payload.type === 'host/workspace-order-changed') { - this.orderFrameGeneration++ - this.installOrder(envelope.payload.workspaceIds, true) - } - else if (envelope.payload.type === 'host/archived-sessions-changed') { - this.installArchived(envelope.payload.archivedSessionIds) - } - } - - /** Re-pull the baseline after each connection generation. */ - handleConnected(): void { - void this.refresh() - } - - /** - * Subscribe to workspace snapshot invalidation. - * @param listener - snapshot invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void { - return this.notifier.subscribe(listener) - } - - /** - * Read the cached workspace snapshot after flushing pending notifications. - * @returns the cached workspace snapshot. - */ - getSnapshot(): WorkspaceListSnapshot { - this.notifier.ensureFresh() - return this.snapshotCache - } - - private buildSnapshot(): WorkspaceListSnapshot { - return { - items: this.itemViews(), - archivedSessionIds: this.archivedSessionIds, - state: this.state, - phase: this.phase, - error: this.error, - } - } - - /** - * Replace the archive set when membership actually changed (array identity - * backs Object.is short-circuits). Host snapshots are append-ordered, so - * positional comparison is exact, not merely heuristic. - */ - private installArchived(archivedSessionIds: readonly SessionId[]): void { - if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true - if (archivedSessionIds.length === this.archivedSessionIds.length - && archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return - this.archivedSessionIds = [...archivedSessionIds] - this.notifier.markDirty() - } - - /** Reorder known Workspace objects, optionally recording a Host-committed sequence. */ - private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void { - if (committed) { - this.refreshFrames?.push({ type: 'order', workspaceIds }) - this.committedOrder = [...workspaceIds] - } - const rank = new Map(workspaceIds.map((id, index) => [id, index])) - const items = [...this.items].sort((left, right) => { - const leftId = left.getSnapshot().view?.workspaceId - const rightId = right.getSnapshot().view?.workspaceId - return (leftId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(leftId) ?? Number.MAX_SAFE_INTEGER) - - (rightId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(rightId) ?? Number.MAX_SAFE_INTEGER) - }) - if (items.every((item, index) => item === this.items[index])) return - this.items = items - this.notifier.markDirty() - } - - /** Upsert one Host view, optionally retaining the local object that materialized it. */ - private upsert(view: WorkspaceView, identity?: Workspace): void { - if (this.removedIds.has(view.workspaceId)) return - this.refreshFrames?.push({ type: 'upsert', workspace: view }) - const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) - // Mutation responses and changed frames race (two carriers, no ordering): - // reject a snapshot strictly older than the installed projection so a - // late unary response cannot roll back a newer frame. - const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view - if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return - if (!this.committedOrder.includes(view.workspaceId)) { - this.committedOrder = [view.workspaceId, ...this.committedOrder] - } - if (identity !== undefined) { - this.items = index === -1 - ? [identity, ...this.items] - : this.items.map((item, position) => position === index ? identity : item) - } else if (index === -1) { - this.items = [new Workspace(this.api, view), ...this.items] - } else { - this.items[index]?.adopt(view) - this.items = [...this.items] - } - this.notifier.markDirty() - } - - /** Remove one id idempotently and retain a tombstone against late echoes. */ - private remove(workspaceId: WorkspaceId, direct = false): void { - this.refreshFrames?.push({ type: 'remove', workspaceId }) - this.removedIds.add(workspaceId) - this.committedOrder = this.committedOrder.filter(id => id !== workspaceId) - const items = this.items.filter(item => - item.getSnapshot().view?.workspaceId !== workspaceId) - if (items.length === this.items.length) { - // The Host frame may have removed the row first but left its batched - // notification pending. A successful unary echo still flushes that - // committed state before the user action resolves. - if (direct) this.notifier.notifyNow() - return - } - this.items = items - if (direct) this.notifier.notifyNow() - else this.notifier.markDirty() - } - - private installViews(views: readonly WorkspaceView[]): void { - const existing = new Map( - this.items.flatMap((workspace) => { - const view = workspace.getSnapshot().view - return view === undefined ? [] : [[view.workspaceId, workspace] as const] - }), - ) - const installed = new Map() - for (const view of views) { - const duplicate = installed.get(view.workspaceId) - if (duplicate !== undefined) { - duplicate.adopt(view) - continue - } - const workspace = existing.get(view.workspaceId) ?? new Workspace(this.api, view) - workspace.adopt(view) - installed.set(view.workspaceId, workspace) - } - this.items = [...installed.values()] - this.committedOrder = views.map(view => view.workspaceId) - } - - private itemViews(): readonly WorkspaceView[] { - if (this.itemViewsSource === this.items) return this.itemViewsCache - this.itemViewsSource = this.items - this.itemViewsCache = this.items.flatMap((workspace) => { - const view = workspace.getSnapshot().view - return view === undefined ? [] : [view] - }) - return this.itemViewsCache - } -} - -/** Known ids retain their position; a newly created Workspace enters first. */ -function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceView): WorkspaceView[] { - const index = items.findIndex(item => item.workspaceId === workspace.workspaceId) - return index === -1 - ? [workspace, ...items] - : items.map((item, position) => position === index ? workspace : item) -} - -/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */ -function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { - if (delta.type === 'upsert') return upsertWorkspace(items, delta.workspace) - if (delta.type === 'remove') { - return items.filter(workspace => workspace.workspaceId !== delta.workspaceId) - } - const rank = new Map(delta.workspaceIds.map((id, index) => [id, index])) - return [...items].sort((left, right) => - (rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER) - - (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER)) -} - -/** Move one known id before an optional anchor; unknown ids leave the order unchanged. */ -function insertIdBefore( - ids: readonly WorkspaceId[], - id: WorkspaceId, - beforeId?: WorkspaceId, -): WorkspaceId[] { - if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) { - return [...ids] - } - const without = ids.filter(candidate => candidate !== id) - const at = beforeId === undefined ? without.length : without.indexOf(beforeId) - return [...without.slice(0, at), id, ...without.slice(at)] -} diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts deleted file mode 100644 index c23e3b3a6c..0000000000 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** WorkspaceRuntime projects the Workspace object manager for UI consumers. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { - DirectoryListing, IApiClient, RpcError, - SessionId, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '../contract/store.ts' -import { createSnapshotStore } from '../contract/store.ts' -import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts' -import type { IWorkspaces } from '../contract/workspaces.ts' -import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts' - -/** Workspace list plus the two-baseline readiness and default-target projection. */ -export interface WorkspaceListState { - items: readonly WorkspaceView[] - /** - * Registry-global archive set in Host order: grouping surfaces hide these - * sessions everywhere (workspace groups and the ungrouped bucket) while - * their session logs and workspace accounting slots remain. A plain array - * (store-engine vocabulary; immer drafts reject Sets) — membership lookups - * build their own transient Set. - */ - archivedSessionIds: readonly SessionId[] - state: 'idle' | 'loading' | 'error' - phase: WorkspaceListPhase - error: RpcError | null - /** True only after both workspace.list and session.list have succeeded. */ - baselinesReady: boolean - /** Most recently active Workspace, derived without changing `items` order. */ - recentWorkspaceId: WorkspaceId | undefined -} - -/** Structured create failure for UI flows that distinguish Host business errors. */ -export class WorkspaceCreateError extends Error { - constructor(readonly rpcError: RpcError) { - super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`) - this.name = 'WorkspaceCreateError' - } -} - -/** Structured browse failure so the directory browser can branch on Host business codes. */ -export class DirectoryBrowseError extends Error { - constructor(readonly rpcError: RpcError) { - super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`) - this.name = 'DirectoryBrowseError' - } -} - -/** Real Workspace object layer and Host actions. */ -export class WorkspaceRuntime implements IWorkspaces { - /** UI-facing immutable projection; the manager remains wire truth. */ - readonly list: SnapshotStore - /** Workspace baseline and frame owner. */ - private readonly manager: WorkspaceManager - /** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */ - private readonly connecting = new Map>() - /** Guards the runtime-owned one-shot initial-selection subscription. */ - private initialSelectionStarted = false - - /** - * @param ctx - client root context. - * @param api - shared wire client. - * @param sessions - cross-domain sessions face used for recency and blank-session reuse. - */ - constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) { - this.manager = new WorkspaceManager(api) - this.list = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null, - baselinesReady: false, recentWorkspaceId: undefined, - }) - this.manager.subscribe(() => { this.project() }) - this.sessions.list.subscribe(() => { this.project() }) - ctx.reflect.provide('workspaces', this, undefined) - } - - /** - * Resolve the session a New Session flow lands in once this Workspace is - * chosen: reuse the workspace's existing blank session when one is in the - * list mirror, else create a fresh one on the host (`session.create` births - * the full Session+Agent — the client holds no intermediate state). The - * caller owns navigation: take the returned id to `sessions.open`. - * Resolution guarantee (both arms): the returned id is already in the list - * store and `sessions.binding(id)` resolves synchronously — draft hand-off - * may write the new scope's machine before opening. - * @param workspaceId - chosen Workspace (must be in the workspace list). - * @returns the reused or newly created session id. - */ - async connectWorkspace(workspaceId: WorkspaceId): Promise { - const workspace = this.list.getSnapshot().items.find(item => item.workspaceId === workspaceId) - if (workspace === undefined) throw new Error(`workspaces.connectWorkspace: unknown workspace ${workspaceId}`) - // Coalesce concurrent connects: a create's summary lands without cwd - // until the host frame arrives, so a second call inside that window - // would miss the reuse scan and mint another hidden blank session. - const inflight = this.connecting.get(workspaceId) - if (inflight !== undefined) return inflight - // Reuse requires workspace membership (id in sessionIds AND same - // canonical cwd — the host's own membership rule), never cwd alone: - // a cwd match can belong to no account (sessions the CLI/TUI birthed at - // the host cwd, or a deleted/recreated registration) and reusing it - // would open a session no grouping surface shows under this workspace. - // An archived blank is never reused either: reuse would open a session - // no grouping surface can show, so New Session mints a fresh one instead. - const archived = this.list.getSnapshot().archivedSessionIds - const sessions = this.sessions.list.getSnapshot() - for (const id of sessions.ids) { - const summary = sessions.byId[id] - if (summary !== undefined && summary.blank && summary.cwd === workspace.path - && workspace.sessionIds.includes(summary.id) - && !archived.includes(summary.id)) return summary.id - } - const attempt = this.sessions.create({ workspaceId }) - .finally(() => { this.connecting.delete(workspaceId) }) - this.connecting.set(workspaceId, attempt) - return attempt - } - - /** - * Follow the first complete Workspace/Session baseline and select a default - * session exactly once. A restored current session wins; otherwise the most - * recent Workspace is connected (reusing or creating its blank session). - * Later explicit clears stay cleared instead of retriggering this startup - * policy. A failed connect may retry on the next baseline projection. - * @returns disposer for the baseline subscription; late work cannot navigate after disposal. - */ - startInitialSelection(): () => void { - if (this.initialSelectionStarted) { - throw new Error('workspaces.startInitialSelection: already started') - } - this.initialSelectionStarted = true - let state: 'waiting' | 'connecting' | 'done' = 'waiting' - let disposed = false - const reconcile = (): void => { - if (disposed || state !== 'waiting') return - const workspace = this.list.getSnapshot() - if (!workspace.baselinesReady) return - const current = this.sessions.list.getSnapshot().current - const target = workspace.recentWorkspaceId - if (current !== undefined || target === undefined) { - state = 'done' - return - } - state = 'connecting' - void this.connectWorkspace(target).then( - (sessionId) => { - if (disposed) return - if (this.sessions.list.getSnapshot().current === undefined) { - this.sessions.open(sessionId) - } - state = 'done' - }, - (reason: unknown) => { - if (disposed) return - state = 'waiting' - console.warn('initial workspace selection failed:', reason) - }, - ) - } - const unsubscribe = this.list.subscribe(reconcile) - reconcile() - return () => { - disposed = true - unsubscribe() - } - } - - /** - * The shared New Session action behind the shell entry points (sidebar - * button, workspace browser): resolve the target Workspace — explicit wins, - * then the current Session's Workspace, then the recent-Workspace - * projection — connect its blank session and navigate there; with no - * Workspace at all, clear the selection into the New Session view state. - * Connect failures are non-fatal (console diagnostics; the current view - * stays usable). - * @param workspaceId - explicit target Workspace for scoped actions. - */ - startSession(workspaceId?: WorkspaceId): void { - const workspace = this.list.getSnapshot() - const current = this.sessions.list.getSnapshot().current - const currentWorkspaceId = current === undefined - ? undefined - : workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId - const target = workspaceId ?? currentWorkspaceId ?? workspace.recentWorkspaceId - if (target === undefined) { - this.sessions.clear() - return - } - void this.connectWorkspace(target).then( - (sessionId) => { this.sessions.open(sessionId) }, - (reason: unknown) => { console.warn('new session failed:', reason) }, - ) - } - - /** - * Register an existing path as a Workspace. - * @param input - the Host create payload. - * @returns the created or idempotently resolved Workspace. - */ - async create(input: { path: string }): Promise { - const result = await this.manager.create(input) - if (!result.ok) throw new WorkspaceCreateError(result.error) - return result.value.workspace - } - - /** - * Open the Host's native directory picker (the `native` capability). - * @returns the selected path, or null when the user cancelled. - */ - async pickDirectory(): Promise { - const response = await this.api.host.pickDirectory({}) - if (!response.result.ok) { - throw new Error(`directory picker failed: ${response.result.error.message}`) - } - return response.result.value.path - } - - /** - * List one directory level through the Host's `browse` capability. - * @param path - absolute directory to list; absent lists the Host home directory. - * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. - * @returns the level's listing with breadcrumb ancestry. - */ - async listDirectory(path?: string, signal?: AbortSignal): Promise { - const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value - } - - /** - * Create one child directory through the Host's `browse` capability. - * @param path - absolute existing parent directory. - * @param name - single non-blank path segment. - * @returns the created directory's absolute path. - */ - async createDirectory(path: string, name: string): Promise { - const response = await this.api.host.createDirectory({ path, name }) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value.path - } - - /** - * Open a filesystem path with the Host operating system's default application. - * @param path - absolute or host-resolvable path. - */ - async openPath(path: string): Promise { - const response = await this.api.host.openPath({ path }) - if (!response.result.ok) { - throw new Error(`path open failed: ${response.result.error.message}`) - } - } - - /** - * Rename a Workspace. - * @param workspaceId - target workspace. - * @param title - new display title (trimmed non-empty by the Host). - * @returns the renamed Workspace view. - */ - async rename(workspaceId: WorkspaceId, title: string): Promise { - const result = await this.manager.rename(workspaceId, title) - if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`) - return result.value.workspace - } - - /** - * Delete one Workspace registration. Sessions, session logs, and the - * directory remain Host-owned outside this operation. - * @param workspaceId - target workspace. - */ - async delete(workspaceId: WorkspaceId): Promise { - const result = await this.manager.delete(workspaceId) - if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) - } - - /** - * Move a Workspace within the durable registry display order. - * @param workspaceId - Workspace to move. - * @param beforeWorkspaceId - Anchor workspace; omitted appends. - */ - async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { - const result = await this.manager.insertBefore(workspaceId, beforeWorkspaceId) - if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`) - } - - /** - * Archive a session into the registry-global set. Clearing an archived - * current selection is the projection sweep's job (one rule for the local - * echo and a remote tab's frame alike). - * @param sessionId - session to archive. - */ - async archiveSession(sessionId: SessionId): Promise { - const result = await this.manager.archiveSession(sessionId) - if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`) - } - - /** - * Move a session within its Workspace's manual order (DOM-insertBefore-like). - * @param workspaceId - owning workspace. - * @param sessionId - accounted session to move. - * @param beforeSessionId - accounted anchor to insert before; omitted appends. - * @returns the updated Workspace view. - */ - async insertSessionBefore( - workspaceId: WorkspaceId, - sessionId: SessionId, - beforeSessionId?: SessionId, - ): Promise { - const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId) - if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`) - return result.value.workspace - } - - /** - * Refresh the workspace baseline, reusing an in-flight pull. - * @returns completion of the current or newly started workspace baseline pull. - */ - refresh(): Promise { - return this.manager.refresh() - } - - /** - * Route a Host stream envelope into the Workspace object layer. - * @param envelope - validated Host stream envelope. - */ - handleHostEnvelope(envelope: Parameters[0]): void { - this.manager.handleHostEnvelope(envelope) - } - - /** Rebuild the Workspace baseline after connection. */ - handleConnected(): void { - this.manager.handleConnected() - } - - private project(): void { - const workspace = this.manager.getSnapshot() - const sessions = this.sessions.list.getSnapshot() - const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready' - // An archived current selection clears into the New Session view state — - // a hidden row must not stay open behind the list. Sweeping here covers - // every install path with one rule: the local unary echo, another tab's - // changed frame, and a reconnect baseline restoring a persisted - // selection that was archived while this client was away. - if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) { - this.sessions.clear() - } - this.list.set({ - items: workspace.items, - archivedSessionIds: workspace.archivedSessionIds, - state: workspace.state, - phase: workspace.phase, - error: workspace.error, - baselinesReady, - recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined, - }) - } -} - -/** Stable tie-breaking follows Host Workspace order. */ -function recentWorkspace( - workspaces: readonly WorkspaceView[], - sessions: SessionsPortList['byId'], -): WorkspaceId | undefined { - let selected: WorkspaceId | undefined - let selectedTime = Number.NEGATIVE_INFINITY - for (const workspace of workspaces) { - let latest = Number.NEGATIVE_INFINITY - for (const sessionId of workspace.sessionIds) { - const session = sessions[sessionId] - if (session !== undefined) latest = Math.max(latest, session.updatedAt) - } - if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt) - if (selected === undefined || latest > selectedTime) { - selected = workspace.workspaceId - selectedTime = latest - } - } - return selected -} diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts deleted file mode 100644 index a9eec992a3..0000000000 --- a/packages/client/runtime/src/client/workspaces/workspace.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** React-free Workspace entity with a client-local materialization lifecycle. */ - -import type { - IApiClient, RpcResult, WorkspaceView, -} from '@deepseek-ai/dsh-api-remotes/client' -import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { ObservableSnapshot } from '../contract/store.ts' -import { Notifier } from '../sessions/notifier.ts' - -/** Host input retained by a local Workspace until materialization succeeds. */ -export type WorkspaceCreateInput = { path: string } - -/** Observable state of a client-local Workspace intent. */ -export interface WorkspaceIntentSnapshot { - name: string - phase: 'ready' | 'creating' - error?: string -} - -/** A Workspace is either a local intent or a materialized Host view. */ -export interface WorkspaceSnapshot { - view: WorkspaceView | undefined - intent: WorkspaceIntentSnapshot | undefined -} - -interface WorkspaceIntent { - input: WorkspaceCreateInput - snapshot: WorkspaceIntentSnapshot -} - -/** - * Observable Workspace object whose identity survives Host materialization. - * Local instances retain their create input and failure state; materialized - * instances expose the latest Host view. - */ -export class Workspace implements ObservableSnapshot { - private view: WorkspaceView | undefined - private intent: WorkspaceIntent | undefined - private materialization: Promise> | null = null - private snapshotCache: WorkspaceSnapshot - private readonly notifier = new Notifier(() => { - this.snapshotCache = this.buildSnapshot() - }) - - /** - * @param api - shared wire client. - * @param source - local create input or an existing Host Workspace view. - */ - constructor(private readonly api: IApiClient, source: WorkspaceCreateInput | WorkspaceView) { - if ('workspaceId' in source) { - this.view = source - } else { - this.intent = { - input: source, - snapshot: { name: intentName(source), phase: 'ready' }, - } - } - this.snapshotCache = this.buildSnapshot() - } - - /** - * Materialize this local Workspace through the Host create API. - * Re-entry shares the in-flight completion; a materialized instance returns undefined. - * @returns the Host result, or undefined when this Workspace is already materialized. - */ - materialize(): Promise> | undefined { - if (this.materialization !== null) return this.materialization - const intent = this.intent - if (intent === undefined) return undefined - intent.snapshot = { name: intent.snapshot.name, phase: 'creating' } - this.notifier.notifyNow() - const completion = this.completeMaterialization(intent).finally(() => { - if (this.materialization === completion) this.materialization = null - }) - this.materialization = completion - return completion - } - - /** - * Adopt a Host view without replacing this Workspace object. - * An existing materialized identity accepts updates only for the same Workspace id. - * @param view - latest Host projection. - */ - adopt(view: WorkspaceView): void { - if (this.view !== undefined && this.view.workspaceId !== view.workspaceId) { - throw new Error('cannot adopt a different Workspace id') - } - this.view = view - this.intent = undefined - this.notifier.markDirty() - } - - /** - * Subscribe to Workspace snapshot invalidation. - * @param listener - snapshot invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void { - return this.notifier.subscribe(listener) - } - - /** - * Read the cached Workspace snapshot after flushing pending notifications. - * @returns the cached Workspace snapshot. - */ - getSnapshot(): WorkspaceSnapshot { - this.notifier.ensureFresh() - return this.snapshotCache - } - - private async completeMaterialization( - intent: WorkspaceIntent, - ): Promise> { - let result: RpcResult<{ workspace: WorkspaceView; created: boolean }> - try { - result = (await this.api.workspace.create(intent.input)).result - } catch (error) { - result = transportError(error) - } - if (this.intent !== intent) return result - if (result.ok) { - this.adopt(result.value.workspace) - } else { - intent.snapshot = { - name: intent.snapshot.name, - phase: 'ready', - error: `${result.error.code}: ${result.error.message}`, - } - this.notifier.markDirty() - } - return result - } - - private buildSnapshot(): WorkspaceSnapshot { - return { view: this.view, intent: this.intent?.snapshot } - } -} - -function intentName(input: WorkspaceCreateInput): string { - const trimmed = input.path.replace(/[\\/]+$/, '') - return trimmed.split(/[\\/]/).pop() ?? input.path -} diff --git a/packages/client/runtime/src/index.ts b/packages/client/runtime/src/index.ts deleted file mode 100644 index c1ea85d1e5..0000000000 --- a/packages/client/runtime/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Host loader entry for the browser runtime exported from `./client` and `./loader`. */ - -/** Host plugin body — no host-side behavior for the runtime plugin. */ -export function apply(_ctx: unknown): void {} diff --git a/packages/client/runtime/src/invariant.ts b/packages/client/runtime/src/invariant.ts deleted file mode 100644 index c11a014cd9..0000000000 --- a/packages/client/runtime/src/invariant.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-runtime`. - * @module @deepseek-ai/dsh-client-runtime/invariant - */ - -/* jscpd:ignore-start */ -/* oxlint-disable typescript/no-redundant-type-constituents -- - * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty - * in this compilation unit (intersection reads `never`) but consumers merge - * keys in; the rule fires on the empty-map view, not on real redundancy. */ -import type { Context } from '@deepseek-ai/cordis' -import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-runtime' - -/** Cordis companion plugin name. */ -export const name = 'client-runtime-invariant' -/** Service required before the companion can register. */ -export const inject = ['invariants'] - -/** - * Owned relation: every 'slots/changed'(key) emission must observe the - * mutation already applied — SlotCore bumps the key's version synchronously - * before the service re-emits, so a zero version at dispatch time means the - * event fired without (or ahead of) its mutation. - */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.on('internal/dispatch', (_mode, eventName, args) => { - if (eventName !== 'slots/changed') return - const key: unknown = args[0] - if (typeof key !== 'string' || key === '') { - fail("'slots/changed' dispatched without a slot key argument") - return - } - const slots = ctx.get('slots') - // Event payloads carry keys as plain strings; getVersion is statically - // keyed, so restore the SlotMap-key type after the runtime string check. - if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) { - fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`) - } - }, { global: true }) -} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/runtime/tests/client-apply.client.spec.ts b/packages/client/runtime/tests/client-apply.client.spec.ts deleted file mode 100644 index 199181fe2e..0000000000 --- a/packages/client/runtime/tests/client-apply.client.spec.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Runtime plugin browser-half apply: slots + object services mounting over the - * connection handle, stream-loop sink wiring into the object layer, and the - * fiber-scoped loop teardown. - */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' -import type { ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client' -import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' -import TypertRegistry from '@deepseek-ai/dsh-typert-registry' -import * as RuntimeClient from '../src/client/index.ts' -import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts' -import { Session } from '../src/client/sessions/session.ts' -import type { SessionRuntime } from '../src/client/sessions/service.ts' -import type { WorkspaceRuntime } from '../src/client/workspaces/service.ts' -import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts' - -interface Bench { - ctx: Context - api: FakeApiClient - sinks: ConnectionSinks | undefined - stopped: number -} - -async function mount(): Promise { - const ctx = new Context() - await ctx.plugin(TypertRegistry) - const api = new FakeApiClient() - const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } - const handle: ConnectionHandle = { - api, - isLoopback: true, - hostDescription: { - getSnapshot: () => undefined, - subscribe: () => () => {}, - }, - rpc: { - call: () => Promise.reject(new Error('unexpected generic RPC call')), - }, - start: (sinks) => { - bench.sinks = sinks - return { stop: () => { bench.stopped += 1 } } - }, - } - ctx.reflect.provide('connection', handle) - ctx.reflect.provide('remote', {}) - ctx.reflect.provide('remote.commands', fakeRemote().commands) - await ctx.plugin(RuntimeClient).await() - return bench -} - -async function flushMicrotasks(): Promise { - for (let i = 0; i < 12; i++) await Promise.resolve() -} - -describe('runtime client apply', () => { - it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => { - const bench = await mount() - expect(bench.ctx.get('slots') !== undefined).toBe(true) - // The built-in 'root' declaration ships with this package's SlotRegistry - // (the SlotMap 'root' merge lives here). - expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' }) - const sessions = bench.ctx.get('sessions') - const workspaces = bench.ctx.get('workspaces') - expect(sessions !== undefined).toBe(true) - expect(workspaces !== undefined).toBe(true) - // The bound the wire schema enforces, not a per-connection negotiation. - expect((sessions as SessionRuntime).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT) - if (workspaces === undefined) throw new Error('WorkspaceRuntime missing after runtime apply') - expect(bench.sinks).toBeDefined() - - // Frame sinks reach the object layer: a host session-added lands in the list store. - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r1' as never, - payload: { type: 'host/session-added', blank: true, sessionId: 's-new' } as never, - }) - await Promise.resolve() - expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new') - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r-workspace' as never, - payload: { - type: 'host/workspace-changed', - workspace: { - workspaceId: 'w-new', path: '/w/new', title: 'new', sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - }, - } as never, - }) - await Promise.resolve() - expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') - // Mux sink and onConnected route without throwing (manager semantics own the behavior). - bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) - }) - - it('selects the recent Workspace once when the first baselines have no current session', async () => { - const bench = await mount() - bench.api.onWorkspaceList = () => Promise.resolve(ok({ - items: [{ - workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', - }] as never[], - })) - bench.api.onList = () => Promise.resolve(ok({ items: [] })) - - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) - await flushMicrotasks() - - const sessions = bench.ctx.get('sessions') as SessionRuntime - const workspaces = bench.ctx.get('workspaces') as WorkspaceRuntime - expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }]) - expect(sessions.list.getSnapshot().current).toBe('fk-new') - - sessions.clear() - await workspaces.refresh() - await flushMicrotasks() - expect(sessions.list.getSnapshot().current).toBeUndefined() - expect(bench.api.callsOf('session.create')).toHaveLength(1) - }) - - it('wires registry changes into resident Sessions during the runtime apply pass', async () => { - const bench = await mount() - const sessions = bench.ctx.get('sessions') as SessionRuntime - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r-registry' as never, - payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never, - }) - await flushMicrotasks() - expect(sessions.binding('s-registry' as never)).toBeDefined() - const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') - const definition: ConversationNodeDefinition = { - kind: 'registry-probe', - target: 'chat', - match: () => null, - start: () => null, - update: context => context.state, - buildViewNode: () => null, - } - - bench.ctx.conversationEvents.register(definition) - await flushMicrotasks() - - expect(rebuild).toHaveBeenCalledOnce() - rebuild.mockRestore() - }) - - it('stops the stream loop when the plugin fiber unloads', async () => { - const bench = await mount() - const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client')) - // Dispose the whole tree: the ctx.effect teardown must call loop.stop exactly once. - await bench.ctx.fiber.dispose() - expect(bench.stopped).toBe(1) - void fiber - }) -}) diff --git a/packages/client/runtime/tests/conversation-registry.client.spec.ts b/packages/client/runtime/tests/conversation-registry.client.spec.ts deleted file mode 100644 index 4f05a3b15b..0000000000 --- a/packages/client/runtime/tests/conversation-registry.client.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' -import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' -import type { - ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, -} from '../src/client/contract/conversation.ts' -import { Session } from '../src/client/sessions/session.ts' -import { SessionRuntime } from '../src/client/sessions/service.ts' -import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts' - -function eventDefinition(kind: string): ConversationNodeDefinition { - return { - kind, - target: 'chat', - match: () => null, - start: () => null, - update: context => context.state, - buildViewNode: () => null, - } -} - -function viewDefinition(target: string): ConversationViewDefinition { - return { - target, - create: () => ({ - empty: null, - replace: () => null, - apply: () => null, - }), - } -} - -async function bootRegistries(): Promise<{ - ctx: Context - events: ConversationEventRegistry - views: ConversationViewRegistry -}> { - const ctx = new Context() - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() - const events = ctx.get('conversationEvents') as ConversationEventRegistry - const views = ctx.get('conversationViews') as ConversationViewRegistry - return { ctx, events, views } -} - -describe('Conversation registries', () => { - it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => { - const { events } = await bootRegistries() - const definition = eventDefinition('message') - const dispose = events.register(definition) - - expect(events.entries()).toEqual([definition]) - expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/) - - dispose() - dispose() - expect(events.entries()).toEqual([]) - }) - - it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => { - const { events } = await bootRegistries() - const fallback = eventDefinition('unknown') - const dispose = events.registerFallback(fallback) - - expect(events.fallbackEntry()).toBe(fallback) - expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/) - - dispose() - dispose() - expect(events.fallbackEntry()).toBeUndefined() - }) - - it('rejects rendering Definitions that omit either target or builder', async () => { - const { events } = await bootRegistries() - const targetOnly: ConversationNodeDefinition = { - kind: 'target-only', - target: 'chat', - match: () => null, - start: () => null, - update: context => context.state, - } - const builderOnly: ConversationNodeDefinition = { - kind: 'builder-only', - match: () => null, - start: () => null, - update: context => context.state, - buildViewNode: () => null, - } - - expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/) - expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/) - }) - - it('rejects a State-only Definition as the unmatched-event fallback', async () => { - const { events } = await bootRegistries() - const fallback: ConversationNodeDefinition = { - kind: 'state-only-fallback', - match: () => null, - start: () => null, - update: context => context.state, - } - - expect(() => events.registerFallback(fallback)) - .toThrow('conversation fallback Definition must declare a target') - }) - - it('rejects duplicate view targets and disposes a view registration once', async () => { - const { views } = await bootRegistries() - const definition = viewDefinition('chat') - const dispose = views.register(definition) - - expect(views.entries()).toEqual([definition]) - expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/) - - dispose() - dispose() - expect(views.entries()).toEqual([]) - }) - - it('removes Event, fallback, and view contributions with their caller fiber', async () => { - const { ctx, events, views } = await bootRegistries() - const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => { - featureCtx.conversationEvents.register(eventDefinition('message')) - featureCtx.conversationEvents.registerFallback(eventDefinition('unknown')) - featureCtx.conversationViews.register(viewDefinition('chat')) - }) - await feature.await() - - expect(events.entries()).toHaveLength(1) - expect(events.fallbackEntry()).toBeDefined() - expect(views.entries()).toHaveLength(1) - - await feature.dispose() - expect(events.entries()).toEqual([]) - expect(events.fallbackEntry()).toBeUndefined() - expect(views.entries()).toEqual([]) - }) - - it('coalesces registry changes into one rebuild of every resident Session', async () => { - const { ctx, events, views } = await bootRegistries() - const api = new FakeApiClient() - const sessionId = 'resident' as SessionId - api.onList = () => Promise.resolve(ok({ - items: [{ sessionId, updatedAt: 1, running: false, blank: true }], - }) as never) - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - await sessions.refresh() - await Promise.resolve() - sessions.scope(sessionId) - const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') - - events.register(eventDefinition('message')) - views.register(viewDefinition('chat')) - await Promise.resolve() - - expect(rebuild).toHaveBeenCalledOnce() - rebuild.mockRestore() - }) -}) diff --git a/packages/client/runtime/tests/fake-api.client.ts b/packages/client/runtime/tests/fake-api.client.ts deleted file mode 100644 index e5bdaa5f66..0000000000 --- a/packages/client/runtime/tests/fake-api.client.ts +++ /dev/null @@ -1,367 +0,0 @@ -// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo -// data source on a real clock; behavior tests need per-case responses and -// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. -import type { - ClientResponse, HostFrame, IApiClient, ModelSelection, MuxFrame, - RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, - WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-api-remotes/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionRemotes } from '../src/client/sessions/remotes.ts' - -/** Programmable-default workspace row (branded id, ISO-ish times). */ -function fakeWorkspace(id: string, over: Partial = {}): WorkspaceView { - return { - workspaceId: id as WorkspaceId, - path: '/f/ws', - title: 'ws', - sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - ...over, - } -} - -export interface Deferred { - promise: Promise - resolve(value: T): void - reject(error: unknown): void -} - -/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */ -export function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -let nextRpc = 0 - -export function ok(value: T): RpcResponse { - return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } } -} - -export function err(error: RpcError): RpcResponse { - return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } } -} - -type StreamItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } | { kind: 'fail'; error: unknown } - -interface StreamConn { - feed(item: StreamItem): void -} - -/** - * Commands Remote double: the generated face delivers the carrier's outcome, so - * a test that programs nothing sees an empty catalog and an unmatched line. - * @returns the Remote namespaces the session cluster calls. - */ -export function fakeRemote(): SessionRemotes { - return { - commands: { - list: () => Promise.resolve({ ok: true, value: [] }), - execute: () => Promise.resolve({ ok: true, value: undefined }), - }, - } -} - -export class FakeApiClient implements IApiClient { - /** Chronological call record: [method, payload]. */ - readonly calls: { method: string; payload: unknown }[] = [] - - // Programmable slots (defaults answer OK-empty); reassign per case. - onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) - onSearch: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ items: [], hasMore: false })) - onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) - readonly defaultModel: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } - onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) - onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) - onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = - () => Promise.resolve(ok({ events: [], hasMore: false })) - - onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ - current: this.defaultModel, - routable: true, - groups: [{ - id: 'deepseek-official', - name: 'DeepSeek', - models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], - }], - failures: [], - })) - onSelectModel: (payload: { provider: string; model: string }) => - Promise> = - payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) - onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onAttachment: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) - onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - })) - onPickDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: null })) - onOpenPath: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ opened: true as const })) - - onListDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) - - onCreateDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake/new' })) - - private readonly muxConns: StreamConn[] = [] - private readonly hostConns: StreamConn[] = [] - lastSearchSignal: AbortSignal | undefined - - // Parameters carry local structural annotations: the CI lint lane runs - // without built lib/, so IApiClient's indexed-access types collapse to any - // and inferred parameters would trip no-unsafe-argument. - readonly sessions: IApiClient['sessions'] = { - list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)), - search: (payload: unknown, signal?: AbortSignal) => { - this.lastSearchSignal = signal - return this.record('session.search', payload, this.onSearch(payload)) - }, - create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), - history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => - this.record('session.history', payload, this.onHistory(payload)), - models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)), - selectModel: (payload: { provider: string; model: string }) => - this.record('session.selectModel', payload, this.onSelectModel(payload)), - rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), - fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), - prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), - attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), - updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), - cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), - } - - onSubagentList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) - onSubagentHistory: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ events: [], hasMore: false })) - onSubagentPrompt: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ messageId: 'fake-message' as never })) - - onSubagentInterrupt: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ accepted: true as const })) - - readonly subagents: IApiClient['subagents'] = { - list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)), - history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)), - prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)), - interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)), - } - - readonly host: IApiClient['host'] = { - describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), - pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), - listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)), - createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), - openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), - } - - // The archive-set field defaults at the binding below so list stubs keep - // the pre-archive `{ items }` shape; a stub carrying the field wins. - onWorkspaceList: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ items: [] })) - onWorkspaceCreate: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) - - onWorkspaceRename: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) - - onWorkspaceDelete: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ deleted: true })) - - onWorkspaceInsertBefore: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspaceIds: [] })) - - onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) - - onWorkspaceArchiveSession: (payload: unknown) => Promise> = - payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) - - readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => ( - response.result.ok - ? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } } - : response - )) as ReturnType), - create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), - rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), - delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), - insertBefore: (payload: unknown) => - this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)), - insertSessionBefore: (payload: unknown) => - this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), - archiveSession: (payload: unknown) => - this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)), - } - - // Payloads stay `unknown` (lint-lane note above); response rows are the real - // wire shapes so cases can program requires-bearing catalogs and dual-address - // skill lists without casts. - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) - - - readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), - select: (payload: { agentPreset: string }) => - this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), - read: (payload: { agentPreset: string }) => - this.record('agentPreset.read', payload, Promise.resolve(ok({ - agentPreset: payload.agentPreset, trust: 'user' as const, content: '', - }))), - copy: (payload: { agentPreset: string }) => - this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), - openDocument: (payload: { agentPreset: string }) => - this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - remove: (payload: { agentPreset: string }) => - this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), - } - - readonly skills: IApiClient['skills'] = { - list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - } - - readonly goals: IApiClient['goals'] = { - create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), - } - - readonly settings: IApiClient['settings'] = { - describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))), - openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), - replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), - mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), - } - - readonly credentials: IApiClient['credentials'] = { - describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))), - set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))), - unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))), - } - - readonly llm: IApiClient['llm'] = { - providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), - models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))), - discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), - } - - /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ - suppressStreamOpen = false - - /** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them. - * Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */ - holdStreamOpen = false - private heldOpens: (() => void)[] = [] - - releaseStreamOpens(): void { - const held = this.heldOpens - this.heldOpens = [] - for (const fire of held) fire() - } - - readonly events: IApiClient['events'] = { - mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.muxConns, signal, onOpen), - host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen), - } - - onRespond: (message: ClientResponse) => Promise = () => Promise.resolve({ accepted: true }) - - respond(message: ClientResponse): Promise { - return this.record('respond', message, this.onRespond(message)) - } - - /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */ - pushMux(frame: MuxFrame, rpcId?: string): void { - for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) - } - - pushHost(frame: HostFrame, rpcId?: string): void { - for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) - } - - /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ - endStreams(): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' }) - } - - failStreams(error: unknown): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error }) - } - - get openMuxCount(): number { - return this.muxConns.length - } - - callsOf(method: string): unknown[] { - return this.calls.filter(c => c.method === method).map(c => c.payload) - } - - private record(method: string, payload: unknown, response: Promise): Promise { - this.calls.push({ method, payload }) - return response - } - - private async *openStream(registry: StreamConn[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator> { - const inbox: StreamItem[] = [] - let wake: (() => void) | null = null - const conn: StreamConn = { - feed: (item) => { - inbox.push(item) - wake?.() - }, - } - registry.push(conn) - if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen) - else if (!this.suppressStreamOpen) onOpen?.() - try { - while (!signal.aborted) { - while (inbox.length > 0) { - const item = inbox.shift() as StreamItem - if (item.kind === 'end') return - if (item.kind === 'fail') throw item.error - yield item.envelope - } - await new Promise((resolve) => { - wake = resolve - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - wake = null - } - } finally { - registry.splice(registry.indexOf(conn), 1) - } - } -} diff --git a/packages/client/runtime/tests/node-half.client.spec.ts b/packages/client/runtime/tests/node-half.client.spec.ts deleted file mode 100644 index 8a680eb036..0000000000 --- a/packages/client/runtime/tests/node-half.client.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Node half: the empty host apply (Loader governance + dsh.client discovery placeholder). */ -import { describe, expect, it } from 'vitest' -import { apply } from '../src/index.ts' - -describe('node half', () => { - it('apply is a no-op host placeholder', () => { - apply(undefined) - expect(true).toBe(true) // reaching here without throw is the contract - }) -}) diff --git a/packages/client/runtime/tests/session.client.spec.ts b/packages/client/runtime/tests/session.client.spec.ts deleted file mode 100644 index 19e80dfd5b..0000000000 --- a/packages/client/runtime/tests/session.client.spec.ts +++ /dev/null @@ -1,1016 +0,0 @@ -/** - * Session orchestration: drive the object through contract calls and injected - * frames (open → prompt → stream → finalize → cancel → resync) and assert the - * ConversationSnapshot it settles into. Reference stability is asserted with - * toBe/not.toBe — it is the React.memo/uSES contract, equal-value output is not - * enough. - */ - -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-commands/types' -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import { Session } from '../src/client/sessions/session.ts' -import type { - ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, - ConversationEventInput, ConversationNode, ConversationNodeDefinition, - ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot, - ConversationViewDefinition, -} from '../src/client/index.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' -import { entries, ev, plainTurn } from './event-script.client.ts' - -const SID = 'fk-s1' as SessionId -const PARENT = 'fk-parent' as SessionId - -afterEach(() => { - vi.unstubAllGlobals() -}) - -const EMPTY: readonly never[] = [] - -interface TestEventState extends ConversationEventInput {} - -class TestNodeStore implements ChatNodeStore { - private readonly nodes = new Map() - private cache: readonly ChatConversationViewNode[] = EMPTY - - get(key: string): ChatConversationViewNode | undefined { - return this.nodes.get(key) - } - - values(): readonly ChatConversationViewNode[] { - return this.cache - } - - replace(nodes: readonly ChatConversationViewNode[]): void { - this.nodes.clear() - for (const node of nodes) this.nodes.set(node.key, node) - this.cache = [...this.nodes.values()] - } - - upsert(nodes: readonly ChatConversationViewNode[]): void { - if (nodes.length === 0) return - for (const node of nodes) this.nodes.set(node.key, node) - this.cache = [...this.nodes.values()] - } -} - -const TEST_LOCATIONS: ChatLocationNodeIndex = { - getTurn: () => EMPTY, - getStep: () => EMPTY, -} - -function testLegacy( - nodes: readonly ChatConversationViewNode[], - timeline: ConversationTimelineSnapshot, -): ChatSnapshot['legacy'] { - const legacyNodes = nodes.flatMap((node): ConversationNode[] => { - const event = (node.data as TestEventState).event - if (event.type === 'user/message') return [{ kind: 'user', seq: event.seq } as ConversationNode] - if (event.type === 'assistant/message') return [{ kind: 'assistant', seq: event.seq } as ConversationNode] - return [] - }) - const turnTimings = new Map() - const turnEnds = new Map() - for (const turn of timeline.turns.values()) { - if (turn.start !== undefined) { - turnTimings.set(turn.turn, turn.end === undefined - ? { startTime: turn.start.time } - : { startTime: turn.start.time, endTime: turn.end.time }) - } - if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq) - } - return { nodes: legacyNodes, turnTimings, turnEnds, partial: null, runningCalls: EMPTY } -} - -function testViewDefinition(): ConversationViewDefinition { - return { - target: 'chat', - create: () => { - const store = new TestNodeStore() - let current: ChatSnapshot = { - order: EMPTY, - nodes: store, - locations: TEST_LOCATIONS, - timeline: { turnOrder: EMPTY, turns: new Map() }, - legacy: testLegacy(EMPTY, { turnOrder: EMPTY, turns: new Map() }), - } - const build = (timeline: ConversationTimelineSnapshot): ChatSnapshot => { - const nodes = [...store.values()].sort((left, right) => left.anchorSeq - right.anchorSeq) - current = { - order: nodes.map(node => node.key), - nodes: store, - locations: TEST_LOCATIONS, - timeline, - legacy: testLegacy(nodes, timeline), - } - return current - } - return { - empty: current, - replace: ({ nodes, timeline }) => { - store.replace(nodes) - return build(timeline) - }, - apply: ({ upserts, timeline }) => { - store.upsert(upserts) - return build(timeline) - }, - } - }, - } -} - -const TEST_EVENT_DEFINITION: ConversationNodeDefinition = { - kind: 'runtime-test-event', - target: 'chat', - match: event => ({ id: String(event.seq), role: 'start' }), - start: (_context, match) => ({ event: match.event, view: match.view }), - update: context => context.state, - publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate', - buildViewNode: (context) => { - if (context.state === undefined || context.start === undefined) return null - return { - key: context.key, - kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal' - ? 'command-input' - : context.start.event.type === 'command/run' || context.start.event.type === 'command/done' - ? 'command' - : 'runtime-test-event', - id: context.id, - target: 'chat', - anchorSeq: context.start.event.seq, - location: context.start.location, - visibility: 'visible', - data: context.state, - } - }, -} - -const TEST_CONVERSATION: ConversationRuntime = { - events: { - entries: () => [TEST_EVENT_DEFINITION], - fallbackEntry: () => undefined, - } as unknown as ConversationRuntime['events'], - views: { - entries: () => [testViewDefinition()], - } as unknown as ConversationRuntime['views'], -} - -function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, api, fakeRemote(), { conversation: TEST_CONVERSATION }) } -} - -function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] { - return snapshot.chat.order.map(key => snapshot.chat.nodes.get(key)?.data as TestEventState) -} - -function chatSeqs(snapshot: ConversationSnapshot): number[] { - return chatEvents(snapshot).map(item => item.event.seq) -} - -function histResponse(events: SessionEvent[], hasMore = false) { - // history returns HistoryEntry[] ({event, view?}); these tests are view-less. - return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) -} - -describe('open', () => { - it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { - const { session } = makeSession() - expect(session.getSnapshot()).toMatchObject({ blank: true, composerPhase: 'blank' }) - - session.handleRunning(true) - expect(session.getSnapshot()).toMatchObject({ blank: false, composerPhase: 'active' }) - }) - - it('installs the tail page: cold → loading → open with window and nodes in place', async () => { - const { api, session } = makeSession() - const page = plainTurn(10, 3, '问', '答') - api.onHistory = () => histResponse(page, true) - expect(session.getSnapshot().openState).toBe('cold') - const opening = session.open() - expect(session.getSnapshot().openState).toBe('loading') - await opening - const snapshot = session.getSnapshot() - expect(snapshot.openState).toBe('open') - expect(snapshot.hasMore).toBe(true) - expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant']) - expect(snapshot.turnTimings.get(3)).toEqual({ - startTime: 1_700_000_000_010, - endTime: 1_700_000_000_015, - }) - expect(snapshot.turnEnds.get(3)).toBe(15) - }) - - it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => { - const { api, session } = makeSession() - await Promise.all([session.open(), session.open()]) - await session.open() - expect(api.callsOf('session.history')).toHaveLength(1) - }) - - it('lands an error result in openState=error with the RpcError kept', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } })) - await session.open() - const snapshot = session.getSnapshot() - expect(snapshot.openState).toBe('error') - expect(snapshot.openError?.code).toBe('session-not-found') - }) - - it('folds a transport throw into openState=error / internal', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.reject(new Error('socket died')) - await session.open() - expect(session.getSnapshot().openState).toBe('error') - expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' }) - }) - - it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { - const { api, session } = makeSession() - const gate = deferred>>() - api.onHistory = () => gate.promise - const opening = session.open() - // Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15). - const page = plainTurn(10, 0, '早', '安') - session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) }) - session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') }) - gate.resolve(ok({ - events: entries(page) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) - await opening - const seqs = session.getSnapshot().nodes.map(n => n.seq) - // Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once. - expect(seqs).toEqual([11, 13, 16]) - }) -}) - - -describe('live event path', () => { - async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) { - const { api, session } = makeSession() - api.onHistory = () => histResponse(events) - await session.open() - return { api, session } - } - - it('drops replayed frames at or below the window tail', async () => { - const { session } = await opened() - const before = session.getSnapshot() - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(3, '重放') }) - await Promise.resolve() - expect(session.getSnapshot().nodes).toEqual(before.nodes) - }) - - it('keeps the authoritative host blank bit across unrelated log events', async () => { - const { session } = await opened([]) - session.handleBlank(true) - expect(session.getSnapshot().composerPhase).toBe('blank') - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) - feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')) - const snapshot = session.getSnapshot() - expect(chatSeqs(snapshot)).toEqual([0, 1]) - expect(snapshot.composerPhase).toBe('blank') - }) - - it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => { - const { session } = await opened([]) - session.handleBlank(true) - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - feed(ev.commandRun(0, 'cmd-goal', 'goal', ' ')) - feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.')) - - expect(session.getSnapshot()).toMatchObject({ - blank: true, - composerPhase: 'active', - }) - expect(session.getSnapshot().chat.order.map( - key => session.getSnapshot().chat.nodes.get(key)?.kind, - )).toContain('command-input') - }) - - it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => { - const frames: FrameRequestCallback[] = [] - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - frames.push(callback) - return frames.length - }) - const { session } = await opened() - const published: number[][] = [] - session.subscribe(() => { - published.push(chatSeqs(session.getSnapshot())) - }) - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - - feed(ev.chunkStart(6, 1)) - feed(ev.chunkText(7, 1, '累')) - feed(ev.chunkText(8, 1, '计')) - expect(published).toEqual([]) - expect(frames).toHaveLength(1) - - frames.shift()!(0) - expect(published).toEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8]]) - - feed(ev.chunkText(9, 1, '完成')) - feed(ev.assistant(10, 1, '累计完成')) - await Promise.resolve() - expect(published).toEqual([ - [0, 1, 2, 3, 4, 5, 6, 7, 8], - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - ]) - - frames.shift()!(0) - expect(published).toHaveLength(2) - }) - - it('publishes a timeline-only boundary even when no Definition claims the event', async () => { - const api = new FakeApiClient() - api.onHistory = () => histResponse([]) - const conversation: ConversationRuntime = { - events: { - entries: () => [], - fallbackEntry: () => undefined, - } as unknown as ConversationRuntime['events'], - views: { - entries: () => [testViewDefinition()], - } as unknown as ConversationRuntime['views'], - } - const session = new Session(SID, api, fakeRemote(), { conversation }) - await session.open() - const snapshots: ConversationSnapshot[] = [] - session.subscribe(() => { snapshots.push(session.getSnapshot()) }) - - session.handleMuxEnvelope('timeline' as never, { - type: 'session/event', - sessionId: SID, - event: ev.turnStart(0, 1), - }) - await Promise.resolve() - - expect(snapshots).toHaveLength(1) - expect(snapshots[0]?.chat.timeline.turns.get(1)?.status).toBe('open') - }) - - it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { - const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 - const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] - api.onHistory = () => histResponse(repaired) - // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires. - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(9, 1, 'd') }) - await vi.waitFor(() => { - expect(api.callsOf('session.history').length).toBe(2) - }) - await Promise.resolve() - const seqs = session.getSnapshot().nodes.map(n => n.seq) - expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9 - }) -}) - -describe('paging', () => { - it('prepends an older page and keeps seq continuity', async () => { - const older = plainTurn(0, 0, '旧问', '旧答') - const newer = plainTurn(6, 1, '新问', '新答') - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(newer, true) - : histResponse(older, false) - await session.open() - await session.loadOlder() - const snapshot = session.getSnapshot() - expect(api.callsOf('session.history')).toMatchObject([{}, { beforeSeq: 6 }].map(p => ({ sessionId: SID, ...p }))) - expect(snapshot.hasMore).toBe(false) - expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9]) - }) - - it('installs a page without interpreting business replacement metadata', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse([ - ev.compactSummary(80, '窗外范围的摘要', 3, 40), - ev.compactCheckpoint(81, 80, 3, 40), - ev.user(82, '压缩后的新问题'), - ], true) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - await session.open() - const snapshot = session.getSnapshot() - expect(snapshot.openState).toBe('open') - expect(chatSeqs(snapshot)).toEqual([80, 81, 82]) - expect(errorSpy).not.toHaveBeenCalled() - } finally { - errorSpy.mockRestore() - } - }) - - it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => { - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(10, 1, '新', '页'), true) - : histResponse(plainTurn(0, 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - await session.open() - const nodesBefore = session.getSnapshot().nodes - await session.loadOlder() - const snapshot = session.getSnapshot() - expect(snapshot.nodes).toEqual(nodesBefore) - expect(snapshot.hasMore).toBe(false) - } finally { - errorSpy.mockRestore() - } - }) - - it('ignores loadOlder while one is in flight (single request)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) - await session.open() - const gate = deferred>>() - api.onHistory = () => gate.promise - const first = session.loadOlder() - const second = session.loadOlder() - gate.resolve(ok({ - events: entries(plainTurn(0, 0, 'a', 'b')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) - await Promise.all([first, second]) - expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two - }) -}) - -describe('prompt and cancel errors', () => { - it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { - const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) - await session.open() - const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') - const cancelled = await session.cancel() - - expect(prompted).toEqual({ ok: true, value: { accepted: true } }) - expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('subagent.history')).toEqual([ - { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 }, - ]) - expect(api.callsOf('subagent.prompt')).toEqual([ - { - parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', - content: [{ type: 'text', text: '继续' }], - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - ]) - expect(api.callsOf('subagent.interrupt')).toEqual([ - { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - ]) - expect(api.callsOf('session.history')).toEqual([]) - expect(api.callsOf('session.prompt')).toEqual([]) - expect(api.callsOf('session.cancel')).toEqual([]) - // A successful interrupt leaves no stop error behind. - expect(session.getSnapshot().promptError).toBeNull() - expect(session.getSnapshot().subagent).toEqual({ - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) - }) - - it('lands an interrupt business failure in promptError with op=stop', async () => { - const api = new FakeApiClient() - api.onSubagentInterrupt = () => Promise.resolve(err({ - code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, - }) as never) - const session = new Session(SID, api, fakeRemote(), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) - await session.open() - const cancelled = await session.cancel() - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } }) - expect(session.getSnapshot().promptError).toMatchObject({ - op: 'stop', error: { code: 'subagent-unauthorized' }, - }) - }) - - it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { - const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote(), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, - }) - await session.open() - const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') - const cancelled = await session.cancel() - - expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } }) - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) - expect(api.callsOf('subagent.history')).toEqual([ - { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 }, - ]) - expect(api.callsOf('subagent.prompt')).toEqual([]) - expect(api.callsOf('subagent.interrupt')).toEqual([]) - expect(api.callsOf('session.cancel')).toEqual([]) - }) - - it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { - const { api, session } = makeSession() - session.handleBlank(true) - // The blank → engaging edge fires before the RPC settles: the first-send - // flow reads the phase on the session area's first frame to keep the - // guidance hero from flashing back in. - expect(session.getSnapshot().composerPhase).toBe('blank') - const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue') - expect(session.getSnapshot().composerPhase).toBe('engaging') - const result = await inFlight - expect(result.ok).toBe(true) - // Monotone: settlement alone does not step the phase anywhere. - expect(session.getSnapshot().composerPhase).toBe('engaging') - expect(api.callsOf('session.prompt')).toMatchObject([{ - sessionId: SID, - mode: 'queue', - content: [{ type: 'text', text: '要发的' }], - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, - }]) - // First content lands (running turn): engaging → active. - session.handleRunning(true) - expect(session.getSnapshot().composerPhase).toBe('active') - }) - - it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => { - const { api, session } = makeSession() - session.handleBlank(true) - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) - const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') - expect(result.ok).toBe(false) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } }) - // Failed first prompt: composer + error strip is the retry surface — - // blank is unreachable once a send was initiated. - expect(session.getSnapshot().composerPhase).toBe('engaging') - }) - - it('lands cancel failures in promptError with op=stop', async () => { - const { api, session } = makeSession() - api.onCancel = () => Promise.reject(new Error('cancel transport down')) - const result = await session.cancel() - expect(result.ok).toBe(false) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } }) - }) - - it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { - const { api, session } = makeSession() - const result = await session.readAttachment('attachment-1' as never) - expect(result).toEqual({ - ok: true, - value: { - attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, - data: Uint8Array.of(0), - }, - }) - expect(api.callsOf('session.attachment')).toEqual([{ - sessionId: SID, attachmentId: 'attachment-1', - }]) - }) -}) - -describe('rename', () => { - it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => { - const { api, session } = makeSession() - api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 })) - const result = await session.rename(' 正名 ') - expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } }) - expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }]) - expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') - // A stale lower-seq apply (the push-frame path routes into this same - // store) must not roll the settled value back. - session.projections.apply('title', '旧名', 3) - expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') - }) - - it('returns the business error untouched and folds a transport throw to internal', async () => { - const { api, session } = makeSession() - api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } })) - const rejected = await session.rename(' ') - expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } }) - expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() - api.onRename = () => Promise.reject(new Error('rename transport down')) - const folded = await session.rename('x') - expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } }) - }) -}) - -describe('pending interactions', () => { - it('adds approval/question on requested and removes them on resolved', async () => { - const { session } = makeSession() - session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) - session.handleMuxEnvelope('rq' as never, { type: 'question/requested', sessionId: SID, questions: [] }) - expect(session.getSnapshot().pending.map(p => p.kind).sort()).toEqual(['approval', 'question']) - session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap1' as never, outcome: 'approved' as never }) - session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' }) - expect(session.getSnapshot().pending).toEqual([]) - }) - - it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => { - const { api, session } = makeSession() - session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] }) - const wait = session.getSnapshot().pending[0]! - expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } }) - const receipt = await wait.respond({ - ok: true, - value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } }, - }) - expect(receipt).toEqual({ accepted: true }) - expect(api.callsOf('respond')).toEqual([{ - type: 'client-response', rpcId: 'rq-answer', - result: { - ok: true, - value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } }, - }, - }]) - }) - - it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => { - const { api, session } = makeSession() - session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] }) - const wait = session.getSnapshot().pending[0]! - session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' }) - expect(session.getSnapshot().pending).toEqual([]) - expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })) - .toThrow('already settled') - expect(api.callsOf('respond')).toEqual([]) - }) -}) - -describe('remaining branches', () => { - it('prompt transport throw folds to internal promptError', async () => { - const { api, session } = makeSession() - api.onPrompt = () => Promise.reject(new Error('prompt wire down')) - const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue') - expect(result.ok).toBe(false) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } }) - }) - - it('cancel business error also lands op=stop promptError', async () => { - const { api, session } = makeSession() - api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } })) - await session.cancel() - expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } }) - }) - - it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => { - const { api, session } = makeSession() - await session.loadOlder() // cold: no-op, zero calls - expect(api.calls).toEqual([]) - api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) - await session.open() - // err result: window unchanged - api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) - await session.loadOlder() - expect(session.getSnapshot().nodes).toHaveLength(2) - expect(session.getSnapshot().hasMore).toBe(true) - // empty page: hasMore adopts the response - api.onHistory = () => histResponse([], false) - await session.loadOlder() - expect(session.getSnapshot().hasMore).toBe(false) - // hasMore false now: further loadOlder is a guard no-op - const calls = api.calls.length - await session.loadOlder() - expect(api.calls.length).toBe(calls) - // throw path: fail-soft with console.error - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - await session.resync() - api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) - await session.resync() - api.onHistory = () => Promise.reject(new Error('page wire down')) - await session.loadOlder() - expect(errorSpy).toHaveBeenCalled() - expect(session.getSnapshot().loadingOlder).toBe(false) - } finally { - errorSpy.mockRestore() - } - }) - - it('subscribe delivers snapshot-change notifications and unsubscribes', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - let notified = 0 - const unsubscribe = session.subscribe(() => { notified++ }) - await session.open() - await new Promise(resolve => setTimeout(resolve, 0)) - expect(notified).toBeGreaterThan(0) - const seen = notified - unsubscribe() - session.handleRunning(true) // any snapshot mutation; the listener must stay silent - await new Promise(resolve => setTimeout(resolve, 0)) - expect(notified).toBe(seen) - }) - - it('subscribed baseline past the window tail triggers the second stitch pull in doOpen', async () => { - const { api, session } = makeSession() - const full = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] - let call = 0 - api.onHistory = () => { - call++ - return histResponse(call === 1 ? plainTurn(0, 0, 'a', 'b') : full) - } - // Baseline arrives before open: lastSeq 11 > first page tail 5 → doOpen repulls once. - session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 }) - await session.open() - expect(call).toBe(2) - expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([1, 3, 7, 9]) - }) - - it('a failed second stitch pull keeps the first window and still opens', async () => { - const { api, session } = makeSession() - let call = 0 - api.onHistory = () => { - call++ - return call === 1 - ? histResponse(plainTurn(0, 0, 'a', 'b')) - : Promise.resolve(err({ code: 'internal', message: 'stitch pull down', details: {} })) - } - session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 }) - await session.open() - expect(call).toBe(2) - const snapshot = session.getSnapshot() - expect(snapshot.openState).toBe('open') // stitch-pull failure is not an open failure - expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3]) // first window kept - }) - - it('approval frame with callId/reason keeps the optional fields; duplicate resolved is a no-op', () => { - const { session } = makeSession() - session.handleMuxEnvelope('ra' as never, { - type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险', - }) - expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { callId: 'c1', reason: '危险' } }) - session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never }) - session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never }) - session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' }) - expect(session.getSnapshot().pending).toEqual([]) - }) - - it('ignores unknown mux frame types and repeated running flips (documented defaults)', () => { - const { session } = makeSession() - const before = session.getSnapshot() - session.handleMuxEnvelope('rz' as never, { type: 'future/frame' } as never) - session.handleRunning(false) // already false: dedup branch - expect(session.getSnapshot()).toBe(before) - session.handleRemoved() - expect(session.getSnapshot().removed).toBe(true) - }) - - it('drops live events while cold/error (no window upkeep)', async () => { - const { api, session } = makeSession() - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '冷态帧') }) - expect(session.getSnapshot().nodes).toEqual([]) - api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) - await session.open() - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '错态帧') }) - expect(session.getSnapshot().nodes).toEqual([]) - }) - - it('repairGap failure logs and clears stitching; concurrent gaps coalesce into one repair', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const gate = deferred>>() - let repairs = 0 - api.onHistory = () => { - repairs++ - return gate.promise - } - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞一') }) - session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(10, '洞二') }) // stitching: detours, no second repair - expect(repairs).toBe(1) - gate.reject(new Error('repair wire down')) - await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalled() }) - // Window unchanged; a later successful repull still lands the buffered frames. - expect(session.getSnapshot().nodes).toHaveLength(2) - } finally { - errorSpy.mockRestore() - } - }) - - it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => { - const { api, session } = makeSession() - const stale = deferred>>() - api.onHistory = () => stale.promise - const opening = session.open() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - const resynced = session.resync() - stale.reject(new Error('stale wire')) - await Promise.all([opening, resynced]) - expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error - }) - - it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => { - const { api, session } = makeSession() - const stale = deferred>>() - api.onHistory = () => stale.promise - const opening = session.open() - api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代')) - const resynced = session.resync() - stale.resolve(ok({ - events: entries(plainTurn(0, 0, '旧', '代')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'stale' }, - })) // success, but its generation is gone - await Promise.all([opening, resynced]) - expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window - }) - - it('drops a stale stitch pull (second doOpen fetch) superseded mid-flight by resync', async () => { - const { api, session } = makeSession() - const secondPull = deferred>>() - let call = 0 - api.onHistory = () => { - call++ - if (call === 1) return histResponse(plainTurn(0, 0, 'a', 'b')) // first page: tail 5 - if (call === 2) return secondPull.promise // gap-stitch pull: held - return histResponse(plainTurn(6, 1, 'c', 'd')) - } - session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 }) - const opening = session.open() // triggers the second pull, which parks - await vi.waitFor(() => { expect(call).toBe(2) }) - const resynced = session.resync() - secondPull.resolve(ok({ - events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'stale' }, - })) - await Promise.all([opening, resynced]) - expect(session.getSnapshot().openState).toBe('open') - }) - - it('drops a gap repair superseded by a full resync while its pull was in flight', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const repairPull = deferred>>() - api.onHistory = () => repairPull.promise - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap - api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd')) - const resynced = session.resync() // bumps the generation - repairPull.resolve(ok({ - events: entries(plainTurn(0, 0, '旧', '页')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'stale' }, - })) // repair result: stale, dropped - await resynced - expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) - }) - - it('successful cancel leaves no promptError', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const result = await session.cancel() - expect(result.ok).toBe(true) - expect(session.getSnapshot().promptError).toBeNull() - }) - - it('dispose is a reserved no-op on resident instances', () => { - const { session } = makeSession() - expect(() => { session.dispose() }).not.toThrow() - }) - - it('carries history-entry and mux-frame views into the business-neutral Event input', async () => { - const { api, session } = makeSession() - const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } } - api.onHistory = () => Promise.resolve(ok({ - events: [ - ...entries(plainTurn(0, 0, 'a', 'b')), - { event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView }, - { event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } }, - ] as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) - await session.open() - expect(chatEvents(session.getSnapshot()).slice(-2).map(item => item.view)).toEqual([ - callView, - { for: 'result', view: { card: 'generic', title: '历史果' } }, - ]) - session.handleMuxEnvelope('rv1' as never, { - type: 'session/event', sessionId: SID, event: ev.toolCall(8, 2, 'l1', 'write', '{}'), - view: { for: 'call', view: { card: 'generic', title: '直播卡' } }, - } as never) - expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ - for: 'call', view: { card: 'generic', title: '直播卡' }, - }) - session.handleMuxEnvelope('rv2' as never, { - type: 'session/event', sessionId: SID, event: ev.toolResult(9, 2, 'l1', 'ok'), - view: { for: 'result', view: { card: 'generic', title: '直播果' } }, - } as never) - expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ - for: 'result', view: { card: 'generic', title: '直播果' }, - }) - }) -}) - -describe('resync', () => { - it('rebuilds the window and clears pending; cold instances no-op', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) - api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) - await session.resync() - const snapshot = session.getSnapshot() - expect(snapshot.openState).toBe('open') - expect(snapshot.pending).toEqual([]) // baseline replay re-sends still-pending frames - expect(snapshot.nodes).toHaveLength(4) - - const cold = makeSession() - await cold.session.resync() - expect(cold.api.calls).toEqual([]) // never opened: no traffic - }) - - it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] }) - const before = session.getSnapshot().pending[0]! - await session.resync() - session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] }) - const after = session.getSnapshot().pending[0]! - expect(after).not.toBe(before) - expect(after.key).toBe(before.key) - // Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host. - await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }) - expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }]) - }) - - it('drops a stale in-flight open superseded by resync (generation guard)', async () => { - const { api, session } = makeSession() - const stale = deferred>>() - api.onHistory = () => stale.promise - const firstOpen = session.open() - api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代')) - const resynced = session.resync() - stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late - await firstOpen - await resynced - const snapshot = session.getSnapshot() - expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error - expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9]) - }) - -}) - -describe('reference stability (the memo contract)', () => { - it('keeps unchanged node references across an append and swaps the snapshot object', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) - await session.open() - const before = session.getSnapshot() - const firstKey = before.chat.order[0]! - const secondKey = before.chat.order[1]! - const first = before.chat.nodes.get(firstKey) - const second = before.chat.nodes.get(secondKey) - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') }) - const after = session.getSnapshot() - expect(after).not.toBe(before) // top-level swap on change - expect(after.chat.nodes.get(firstKey)).toBe(first) - expect(after.chat.nodes.get(secondKey)).toBe(second) - expect(after.chat.order).toHaveLength(7) - // No change → same snapshot reference. - expect(session.getSnapshot()).toBe(after) - }) - - it('keeps unrelated Session arrays and settled Chat Nodes stable across Event updates', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '底', '座')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.stepStart(7, 1)) - feed(ev.toolCall(8, 1, 'c1', 'echo', '{}')) - session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) - const before = session.getSnapshot() - const settledKey = before.chat.order[0]! - const settledNode = before.chat.nodes.get(settledKey) - feed(ev.chunkStart(9, 1)) - feed(ev.chunkText(10, 1, '与工具无关的流式')) - const after = session.getSnapshot() - expect(after).not.toBe(before) - expect(after.runningCalls).toBe(before.runningCalls) - expect(after.pending).toBe(before.pending) - expect(after.chat.nodes.get(settledKey)).toBe(settledNode) - feed(ev.toolResult(11, 1, 'c1', 'ECHO')) - const resolved = session.getSnapshot() - expect(resolved.pending).toBe(after.pending) - expect(resolved.chat.nodes.get(settledKey)).toBe(settledNode) - feed(ev.assistant(12, 1, '完成')) - expect(session.getSnapshot()).not.toBe(resolved) - }) -}) diff --git a/packages/client/runtime/tests/wire-events.client.spec.ts b/packages/client/runtime/tests/wire-events.client.spec.ts deleted file mode 100644 index fbc4204b0d..0000000000 --- a/packages/client/runtime/tests/wire-events.client.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to - * the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is - * api-gateway's own coverage); each established connection generation emits - * `connection/reset` for generation-scoped cache invalidation. - */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' -import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client' -import TypertRegistry from '@deepseek-ai/dsh-typert-registry' -// Type-only: the api-remotes facade carries both the allowlist's selection seat -// and the owner packages' `./types` declarations, which together give `$on` its -// key face and per-event listener signatures. -import type {} from '@deepseek-ai/dsh-api-remotes/client' -import * as RuntimeClient from '../src/client/index.ts' -import { FakeApiClient, fakeRemote } from './fake-api.client.ts' - -/** - * Compile-time face of `ctx.remote.$on`, asserted by type-checking this file - * rather than by running it: the allowlist narrows the key set, and each - * listener's parameters come from the owner package's own cordis `Events` - * declaration (so a brand cannot be flattened on the way to a consumer). - * @param ctx - any client Context carrying the Remote service. - */ -function forwardedEventContracts(ctx: Context): void { - ctx.remote.$on('settings/document-updated', (namespace, source) => { - // @ts-expect-error -- the brand survives the wire: a bare string is not a SettingsNamespace - const bare: typeof namespace = 'plain-string' - void bare; void namespace; void source - }) - ctx.remote.$on('credentials/reference-updated', () => {}) - ctx.remote.$on('commands/change', () => {}) - ctx.remote.$on('llm/adapters-updated', () => {}) - ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => { - void sessionId; void agentPreset - }) - // @ts-expect-error -- client-local event outside the allowlist - ctx.remote.$on('slots/changed', () => {}) - // @ts-expect-error -- declared host event the allowlist does not select - ctx.remote.$on('skills/change', () => {}) -} -void forwardedEventContracts - -interface Bench { - ctx: Context - sinks: ConnectionSinks | undefined - /** Every `$dispatch` the runtime made, as `[event, ...args]`. */ - dispatched: unknown[][] -} - -async function mount(): Promise { - const ctx = new Context() - await ctx.plugin(TypertRegistry) - const api = new FakeApiClient() - const bench: Bench = { ctx, sinks: undefined, dispatched: [] } - // Stands in for api-gateway's Remote service: this spec owns the carrier's - // handoff, not the fan-out behind it. - ctx.reflect.provide('remote', { - $dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) }, - }) - const handle: ConnectionHandle = { - api, - isLoopback: true, - hostDescription: { - getSnapshot: () => undefined, - subscribe: () => () => {}, - }, - rpc: { - call: () => Promise.reject(new Error('unexpected generic RPC call')), - }, - start: (sinks) => { - bench.sinks = sinks - return { stop: () => {} } - }, - } - ctx.reflect.provide('connection', handle) - ctx.reflect.provide('remote.commands', fakeRemote().commands) - await ctx.plugin(RuntimeClient).await() - return bench -} - -describe('wire event bridge', () => { - it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => { - const bench = await mount() - const seen = bench.dispatched - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r1' as never, - payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, - }) - expect(seen).toEqual([['commands/change']]) - - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r2' as never, - payload: { type: 'host/session-status', sessionId: 's1' as never, running: true }, - }) - expect(seen).toEqual([['commands/change']]) - }) - - it('carries each forwarded event name with its own argument list, unfiltered', async () => { - const bench = await mount() - const seen = bench.dispatched - - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r3' as never, - payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] }, - }) - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r4' as never, - payload: { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] }, - }) - // The carrier does not second-guess the name: selecting what a consumer can - // receive is the allowlist's job, and dropping an unsubscribed name is the - // Remote service's. This plugin republishes whatever the frame carried. - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r5' as never, - payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] }, - }) - - expect(seen).toEqual([ - ['settings/document-updated', 'llm-pi-ai', 7], - ['credentials/reference-updated', 'OPENAI_API_KEY'], - ['nobody/listening', 'ignored'], - ]) - }) - - it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => { - const bench = await mount() - let resets = 0 - bench.ctx.on('connection/reset', () => { resets++ }) - const description = { version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true } - bench.sinks?.onConnected?.(description) - bench.sinks?.onConnected?.(description) // second generation after a reconnect - expect(resets).toBe(2) - }) -}) diff --git a/packages/client/runtime/tests/workspaces-service.client.spec.ts b/packages/client/runtime/tests/workspaces-service.client.spec.ts deleted file mode 100644 index cfdfa98e9b..0000000000 --- a/packages/client/runtime/tests/workspaces-service.client.spec.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client' -import { SessionRuntime } from '../src/client/sessions/service.ts' -import { WorkspaceManager } from '../src/client/workspaces/manager.ts' -import { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from '../src/client/workspaces/service.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' - -const sid = (id: string): SessionId => id as SessionId -const wid = (id: string): WorkspaceId => id as WorkspaceId - -function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView { - return { - workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds, - createdAt, updatedAt: createdAt, - } -} - -describe('WorkspaceManager', () => { - it('replays changed frames over hydration and adopts the durable order on refresh', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - api.onWorkspaceList = () => gate.promise - const manager = new WorkspaceManager(api) - const hydration = manager.refresh() - manager.handleHostEnvelope({ - rpcId: 'changed' as never, - payload: { type: 'host/workspace-changed', workspace: workspace('new') }, - }) - gate.resolve(ok({ items: [workspace('old')] as never[] })) - await hydration - expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' }) - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old']) - - api.onWorkspaceList = () => Promise.resolve(ok({ - items: [workspace('old'), workspace('new')] as never[], - })) - await manager.refresh() - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['old', 'new']) - }) - - it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - api.onWorkspaceList = () => gate.promise - const manager = new WorkspaceManager(api) - const first = manager.refresh() - const second = manager.refresh() - expect(manager.getSnapshot().state).toBe('loading') - gate.resolve(ok({ items: [] })) - await Promise.all([first, second]) - expect(api.callsOf('workspace.list')).toHaveLength(1) - - api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) - await manager.refresh() - expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } }) - api.onWorkspaceList = () => Promise.reject(new Error('wire down')) - await manager.refresh() - expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } }) - }) - - it('creates by path, prepends a new row, and folds failures', async () => { - const api = new FakeApiClient() - const manager = new WorkspaceManager(api) - api.onWorkspaceCreate = payload => Promise.resolve(ok({ - workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'), - created: true, - payload, - } as never)) - await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) - expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }]) - expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created') - - api.onWorkspaceCreate = () => Promise.reject(new Error('create transport')) - await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({ - ok: false, error: { code: 'internal', message: 'create transport' }, - }) - }) - - it('reorders optimistically while newer Host frames outrank unary echoes and failures roll back', async () => { - const api = new FakeApiClient() - api.onWorkspaceList = () => Promise.resolve(ok({ - items: [workspace('one'), workspace('two'), workspace('three')] as never[], - })) - const manager = new WorkspaceManager(api) - await manager.refresh() - - const gate = deferred>>() - api.onWorkspaceInsertBefore = () => gate.promise - const pending = manager.insertBefore(wid('three'), wid('one')) - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) - manager.handleHostEnvelope({ - rpcId: 'newer-order' as never, - payload: { - type: 'host/workspace-order-changed', - workspaceIds: [wid('one'), wid('three'), wid('two')], - }, - }) - gate.resolve(ok({ workspaceIds: [wid('three'), wid('one'), wid('two')] })) - await pending - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) - - api.onWorkspaceInsertBefore = () => Promise.resolve(err({ - code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'three' }, - })) - const rejected = manager.insertBefore(wid('three')) - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) - await expect(rejected).resolves.toMatchObject({ ok: false }) - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) - - api.onWorkspaceInsertBefore = () => Promise.reject(new Error('transport down')) - const disconnected = manager.insertBefore(wid('three'), wid('one')) - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) - await expect(disconnected).rejects.toThrow('transport down') - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) - }) - - it('rolls overlapping rejected reorders back to the last Host-confirmed order', async () => { - const api = new FakeApiClient() - api.onWorkspaceList = () => Promise.resolve(ok({ - items: [workspace('one'), workspace('two'), workspace('three')] as never[], - })) - const manager = new WorkspaceManager(api) - await manager.refresh() - const firstGate = deferred>>() - const secondGate = deferred>>() - let request = 0 - api.onWorkspaceInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise - - const first = manager.insertBefore(wid('three'), wid('one')) - const second = manager.insertBefore(wid('two'), wid('three')) - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) - - firstGate.resolve(err({ - code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: 'three' }, - })) - await expect(first).resolves.toMatchObject({ ok: false }) - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) - - secondGate.resolve(err({ - code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: 'two' }, - })) - await expect(second).resolves.toMatchObject({ ok: false }) - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) - }) - - it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - api.onWorkspaceList = () => gate.promise - const manager = new WorkspaceManager(api) - const hydration = manager.refresh() - manager.handleHostEnvelope({ - rpcId: 'removed' as never, - payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, - }) - gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] })) - await hydration - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) - - manager.handleHostEnvelope({ - rpcId: 'late-change' as never, - payload: { type: 'host/workspace-changed', workspace: workspace('gone') }, - }) - manager.handleHostEnvelope({ - rpcId: 'duplicate-remove' as never, - payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, - }) - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) - }) - - it('removes from the unary delete echo while a refresh is in flight', async () => { - const api = new FakeApiClient() - api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] })) - const manager = new WorkspaceManager(api) - await manager.refresh() - const gate = deferred>>() - api.onWorkspaceList = () => gate.promise - const refresh = manager.refresh() - - await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true }) - expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }]) - expect(manager.getSnapshot().items).toEqual([]) - gate.resolve(ok({ items: [workspace('gone')] as never[] })) - await refresh - expect(manager.getSnapshot().items).toEqual([]) - }) -}) - -describe('WorkspaceRuntime', () => { - it('feeds readiness and recent-Workspace targeting without changing Host order', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - api.onWorkspaceList = () => Promise.resolve(ok({ - items: [ - workspace('stable-first', [], '2026-01-03T00:00:00.000Z'), - workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'), - ] as never[], - })) - await workspaces.refresh() - await Promise.resolve() - expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined }) - - api.onList = () => Promise.resolve(ok({ - items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false, blank: false }] as never[], - })) - await sessions.refresh() - await Promise.resolve() - await Promise.resolve() - expect(workspaces.list.getSnapshot()).toMatchObject({ - baselinesReady: true, - recentWorkspaceId: 'active', - }) - expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active']) - }) - - it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - api.onWorkspaceList = () => Promise.resolve(ok({ - items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[], - })) - api.onList = () => Promise.resolve(ok({ - items: [ - // Stray blank at alpha's path but NOT accounted under alpha (a CLI - // session birthed at the host cwd), sorted before the member blank: - // the scan must skip it and keep looking for a member hit. - { sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' }, - // Blank session parked in alpha (cwd == workspace path canon AND - // accounted under alpha): the reuse hit. - { sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }, - // Non-blank sibling in beta must never be reused. - { sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' }, - // Stray blank at gamma's path but NOT accounted under gamma (a CLI - // session birthed at the host cwd): cwd alone must not hijack it — - // reuse would open a session gamma cannot show, so New Session mints - // a fresh accounted one instead. - { sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' }, - ] as never[], - })) - await Promise.all([workspaces.refresh(), sessions.refresh()]) - await Promise.resolve() - - // Hit: same workspace → the parked member blank comes back (the earlier - // cwd-matching non-member stray is skipped), no create RPC. - await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') - expect(api.callsOf('session.create')).toEqual([]) - // Resolution guarantee: the id is binding-resolvable synchronously. - expect(sessions.binding(sid('s-blank'))).toBeDefined() - - // Miss: beta has only a non-blank session → host create with workspaceId. - api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') })) - await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh') - expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }]) - // Same guarantee on the create arm (draft hand-off writes the machine pre-open). - expect(sessions.binding(sid('s-fresh'))).toBeDefined() - - // Miss: the stray blank matches gamma's path but is not a gamma member → - // never reused, a fresh accounted session is created instead. - api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') })) - await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3') - expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }]) - - // Unknown workspace fails loud instead of silently creating in nowhere. - await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/) - - // An archived blank is never reused: no surface can show it, so New - // Session mints a fresh one for alpha instead. - await workspaces.archiveSession(sid('s-blank')) - api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') })) - await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2') - }) - - it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] })) - api.onList = () => Promise.resolve(ok({ - items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[], - })) - await Promise.all([workspaces.refresh(), sessions.refresh()]) - await Promise.resolve() - const session = sessions.binding(sid('s-blank'))!.session - api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never) - await session.prompt([{ type: 'text', text: 'hi' }], 'queue') - await Promise.resolve() - // Failure leaves blank intact, so the same session is still the reuse hit. - await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') - expect(api.callsOf('session.create')).toEqual([]) - }) - - it('returns created Workspaces and preserves Host business errors', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - api.onWorkspaceCreate = () => Promise.resolve(ok({ - workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true, - })) - await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' }) - expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' }) - expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }]) - api.onWorkspaceCreate = () => Promise.resolve(err({ - code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' }, - })) - const rejected = workspaces.create({ path: '/missing' }) - await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/) - await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError) - }) - - it('passes native directory selection and cancellation through without local state', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) - await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha') - api.onPickDirectory = () => Promise.resolve(ok({ path: null })) - await expect(workspaces.pickDirectory()).resolves.toBeNull() - expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}]) - api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} })) - await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/) - }) - - it('passes listings and creation through the browse wire, wrapping business failures', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const workspaces = new WorkspaceRuntime(ctx, api, new SessionRuntime(ctx, api, fakeRemote())) - const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false } - api.onListDirectory = () => Promise.resolve(ok(listing)) - await expect(workspaces.listDirectory()).resolves.toEqual(listing) - await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing) - // The optional path is omitted from the payload, not sent as undefined. - expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }]) - api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } })) - const listFailure = workspaces.listDirectory('/x') - await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) - await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) - - await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new') - expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }]) - api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } })) - await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } }) - }) - - it('opens a filesystem path through the host without local state', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined() - expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }]) - api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) - await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/) - }) - - it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) - await workspaces.refresh() - await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined() - expect(workspaces.list.getSnapshot().items).toEqual([]) - - api.onWorkspaceDelete = () => Promise.resolve(err({ - code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' }, - })) - await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) - }) - - it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const workspaces = new WorkspaceRuntime(ctx, api, new SessionRuntime(ctx, api, fakeRemote())) - api.onWorkspaceList = () => Promise.resolve(ok({ - items: [workspace('one'), workspace('two')] as never[], - })) - await workspaces.refresh() - api.onWorkspaceInsertBefore = () => Promise.resolve(ok({ - workspaceIds: [wid('two'), wid('one')], - })) - await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined() - expect(api.callsOf('workspace.insertBefore')).toEqual([{ - workspaceId: 'two', beforeWorkspaceId: 'one', - }]) - expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one']) - - api.onWorkspaceInsertBefore = () => Promise.resolve(err({ - code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' }, - })) - await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) - }) - - it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - api.onWorkspaceList = () => Promise.resolve(ok({ - items: [ - workspace('current-home', [sid('current')]), - workspace('recent-home', [sid('recent')]), - ] as never[], - })) - api.onList = () => Promise.resolve(ok({ items: [ - { sessionId: sid('current'), updatedAt: 1, running: false, blank: false }, - { sessionId: sid('recent'), updatedAt: 2, running: false, blank: false }, - ] as never[] })) - await Promise.all([workspaces.refresh(), sessions.refresh()]) - await Promise.resolve() - sessions.open(sid('current')) - const unresolved = new Promise(() => {}) - const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved) - - workspaces.startSession(wid('recent-home')) - await Promise.resolve() - expect(connect).toHaveBeenLastCalledWith(wid('recent-home')) - - workspaces.startSession() - await Promise.resolve() - expect(connect).toHaveBeenLastCalledWith(wid('current-home')) - - sessions.clear() - workspaces.startSession() - await Promise.resolve() - expect(connect).toHaveBeenLastCalledWith(wid('recent-home')) - - const emptyCtx = new Context() - const emptyApi = new FakeApiClient() - const emptySessions = new SessionRuntime(emptyCtx, emptyApi, fakeRemote()) - const emptyWorkspaces = new WorkspaceRuntime(emptyCtx, emptyApi, emptySessions) - const clear = vi.spyOn(emptySessions, 'clear') - emptyWorkspaces.startSession() - expect(clear).toHaveBeenCalledOnce() - }) - - it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - api.onList = () => Promise.resolve(ok({ - items: [ - { sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false }, - { sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false }, - ], - }) as never) - await sessions.refresh() - sessions.open(sid('s-open')) - - // Archiving a non-current session installs the unary echo and keeps the selection. - await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined() - expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }]) - expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle']) - expect(sessions.list.getSnapshot().current).toBe('s-open') - - // Archiving the current session clears it into the New Session view state. - api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] })) - await workspaces.archiveSession(sid('s-open')) - expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open']) - expect(sessions.list.getSnapshot().current).toBeUndefined() - - // A Host failure leaves the set and the selection untouched. - api.onWorkspaceArchiveSession = () => Promise.resolve(err({ - code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') }, - })) - await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/) - expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open']) - - // The changed frame and the list baseline both re-install the full set. - workspaces.handleHostEnvelope({ - rpcId: 'frame' as never, - payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] }, - } as never) - // Frame installs ride the notifier's microtask batch before projecting. - await new Promise(resolve => setTimeout(resolve, 0)) - expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle']) - api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never) - await workspaces.refresh() - expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open']) - }) - - it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - api.onList = () => Promise.resolve(ok({ - items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }], - }) as never) - await sessions.refresh() - sessions.open(sid('s-open')) - - // A stale baseline is in flight (older, empty set) when another tab's - // archive frame lands: the frame clears the current selection and its - // set survives the baseline's later resolution. - const gate = deferred>>() - api.onWorkspaceList = () => gate.promise - const hydration = workspaces.refresh() - workspaces.handleHostEnvelope({ - rpcId: 'frame' as never, - payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] }, - } as never) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(sessions.list.getSnapshot().current).toBeUndefined() - gate.resolve(ok({ items: [], archivedSessionIds: [] })) - await hydration - expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open']) - // The next (fresh) baseline is authoritative again. - api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never) - await workspaces.refresh() - expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([]) - }) -}) - -describe('startInitialSelection', () => { - function bench() { - const ctx = new Context() - const api = new FakeApiClient() - const sessions = new SessionRuntime(ctx, api, fakeRemote()) - const workspaces = new WorkspaceRuntime(ctx, api, sessions) - return { api, sessions, workspaces } - } - - it('connects the recent Workspace blank session once baselines are ready and opens it', async () => { - const b = bench() - const stop = b.workspaces.startInitialSelection() - // Nothing happens before both baselines land. - expect(b.api.callsOf('session.create')).toHaveLength(0) - - b.api.onWorkspaceList = () => Promise.resolve(ok({ - items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[], - })) - b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') })) - await b.workspaces.refresh() - await b.sessions.refresh() - // Store notifications and the connect round trip are microtask-batched. - await new Promise(resolve => setTimeout(resolve, 0)) - expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }]) - expect(b.sessions.list.getSnapshot().current).toBe('s-new') - stop() - }) - - it('stays idle when a session is already current or no recent Workspace exists', async () => { - const withCurrent = bench() - withCurrent.api.onList = () => Promise.resolve(ok({ - items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[], - })) - await withCurrent.sessions.refresh() - withCurrent.sessions.open(sid('s1')) - withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] })) - const stopCurrent = withCurrent.workspaces.startInitialSelection() - await withCurrent.workspaces.refresh() - await new Promise(resolve => setTimeout(resolve, 0)) - expect(withCurrent.api.callsOf('session.create')).toHaveLength(0) - stopCurrent() - - const noRecent = bench() - const stopEmpty = noRecent.workspaces.startInitialSelection() - await noRecent.workspaces.refresh() - await noRecent.sessions.refresh() - await new Promise(resolve => setTimeout(resolve, 0)) - expect(noRecent.api.callsOf('session.create')).toHaveLength(0) - expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/) - stopEmpty() - }) - - it('a failed connect returns to waiting and retries on the next list change', async () => { - const b = bench() - b.api.onWorkspaceList = () => Promise.resolve(ok({ - items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[], - })) - b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} })) - const stop = b.workspaces.startInitialSelection() - await b.workspaces.refresh() - await b.sessions.refresh() - await new Promise(resolve => setTimeout(resolve, 0)) - expect(b.api.callsOf('session.create')).toHaveLength(1) - expect(b.sessions.list.getSnapshot().current).toBeUndefined() - - // Recovery: the next workspace-list change re-runs the reconcile. - b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') })) - await b.workspaces.refresh() - await new Promise(resolve => setTimeout(resolve, 0)) - expect(b.api.callsOf('session.create')).toHaveLength(2) - expect(b.sessions.list.getSnapshot().current).toBe('s-retry') - stop() - }) -}) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/store/README.i18n.yaml similarity index 71% rename from packages/client/runtime/README.i18n.yaml rename to packages/client/store/README.i18n.yaml index 270d99652d..a67f7a84ec 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/store/README.i18n.yaml @@ -1,6 +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 packages/client/runtime/README.md -README.md: 75c409fa3f1b517e95a48505122e9c21b6f9b0cc -README.zh.md: 3aca97e7e641a2e9dd81f399348bac9c109dbc0e +# pnpm run verify-translation-pairing --write packages/client/store/README.md +README.md: 51299c9730157eaf1cd7226d64a54e08b497b76e +README.zh.md: 1f2883db9a0b591a12438eba96f2fb36d96fbda0 diff --git a/packages/client/store/README.md b/packages/client/store/README.md new file mode 100644 index 0000000000..51299c9730 --- /dev/null +++ b/packages/client/store/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-store + +English | [中文](README.zh.md) + +React-free observable and snapshot-store primitives shared by Client controllers and renderer adapters. The package owns synchronous and animation-frame publication, Immer-backed updates, shallow equality, and optional browser persistence; React hook construction remains in `@deepseek-ai/dsh-client-ui-renderer`. + +## Model Experience + +None, as this package provides browser-side state primitives and registers nothing model-facing. + +#### KV Cache effect + +None; the stores neither assemble nor send model requests. + +## Known Limitations and Deferred Work + +- **Persistence is browser-local** — persisted stores use JSON in `localStorage`; non-browser runtimes disable persistence, and the package provides no cross-device synchronization. diff --git a/packages/client/store/README.zh.md b/packages/client/store/README.zh.md new file mode 100644 index 0000000000..1f2883db9a --- /dev/null +++ b/packages/client/store/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-store + +[English](README.md) | 中文 + +供 Client controller 与 renderer adapter 共用的不依赖 React 的 observable 和 snapshot-store 基础设施。本包负责同步与 animation-frame 发布、基于 Immer 的更新、浅比较和可选的浏览器持久化;React hook 的构造仍属于 `@deepseek-ai/dsh-client-ui-renderer`。 + +## 模型体验 + +无,因为本包提供浏览器侧状态基础设施,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;这些 store 既不组装也不发送模型请求。 + +## 已知限制与暂缓事项 + +- **持久化仅限浏览器本地**——持久化 store 使用 `localStorage` 中的 JSON;非浏览器运行时会禁用持久化,本包也不提供跨设备同步。 diff --git a/packages/client/store/package.json b/packages/client/store/package.json new file mode 100644 index 0000000000..f2d04490ba --- /dev/null +++ b/packages/client/store/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-client-store", + "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/store" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "MIT", + "dependencies": { + "immer": "^10.1.1", + "zustand": "~4.4.7" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/store/src/contract.ts b/packages/client/store/src/contract.ts new file mode 100644 index 0000000000..38e39da024 --- /dev/null +++ b/packages/client/store/src/contract.ts @@ -0,0 +1,144 @@ +/** Framework-neutral snapshot and store contracts. */ + +/** Minimal observable snapshot source shared by controllers, stores, and render adapters. */ +export interface ObservableSnapshot { + /** Read the cached snapshot reference. */ + getSnapshot(): T + /** + * Subscribe to snapshot invalidation. + * @param fn - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(fn: () => void): () => void +} + +/** + * Typed selector hook over a snapshot source. Canonical shape for the whole + * slot system (ui-renderer's engine hook is structurally identical; the + * framework is the only party that ever constructs one). + */ +export type SnapshotSelectorHook = (sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S + +/** + * Selector hook over a source that follows the current session. The hook is + * always present, while its selected value is absent whenever no session is + * current. This keeps hook call sites stable across no-session/session + * transitions without pretending that a session snapshot exists. + */ +export type MaybeSnapshotSelectorHook = + (sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S | undefined + +/** + * Action declaration table: pure immer-draft transforms over the store state, + * declared as the store's complete write set (the audit face — components can + * only write through these). + */ +/* oxlint-disable-next-line typescript/no-explicit-any -- + * any[] (not unknown[]): each action carries its own parameter list, and + * unknown[] would reject every concrete signature under strict parameter + * contravariance. Params are re-inferred per action by BakedActions. */ +export type ActionsDecl = Record void> + +/** + * Draft-stripped callback form of an actions table: what components + * (`props.actions`) and inject factories receive — the framework bakes the + * draft parameter away by binding each action to the resolved instance. + */ +export type BakedActions> = { + [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never +} + +/** + * Store declaration spec: initial-state factory (a lambda so every instance + * gets a fresh state), optional persistence key (mechanical, framework-run), + * and the actions write set. + */ +export interface StoreSpec> { + init: () => T + persist?: string + actions: A +} + +/** + * Live engine instance: the create() product consumed by the render machinery + * and by tests. A bare snapshot source plus the baked write set — no React + * hook rides the engine product (the engine lives in this React-free package); + * the render machinery binds the `useStore` hook from this source on its own + * side, cached per instance. Production components and render paths never + * call create() themselves — instance lifecycle is the framework's. + */ +export interface StoreInstance> { + readonly actions: BakedActions + getSnapshot(): T + /** + * Subscribe to state changes (uSES subscribe side). + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(fn: () => void): () => void + /** + * Drop this instance's persisted value (no-op for non-persist specs). The + * framework calls it when the owning scope dies for good — a pruned session + * must not leave orphaned storage keys behind. + */ + clearPersisted(): void +} + +/** + * Store handle: spec + state/actions types + shared identity + instance + * factory in one value. Handles are constructed in apply world (shared across + * registrations of one plugin) or by the framework from a registrant's + * factory (exclusive). Never export a handle at module level — module-cache + * identity is a disguised singleton across plugin reloads. + */ +export interface StoreHandle> { + readonly spec: StoreSpec + /** + * Create a live engine instance (framework machinery and tests only). + * @param scopeKey - session id for session-scope instances; suffixes the + * persist key so per-session instances persist independently (root-scope + * instances omit it). + * @returns a fresh instance seeded from `spec.init()`. + */ + create(scopeKey?: string): StoreInstance +} + +/** + * Exclusive-store registration form: the registrant passes the factory itself + * and the framework calls it per entry x scope (no shared identity exists). + */ +/* oxlint-disable-next-line typescript/no-explicit-any -- + * erased position accepting every StoreHandle instantiation; T/A are + * recovered per use site by conditional inference (HandleOf/BoundActions/ + * PropsStore). */ +export type StoreFactory = () => StoreHandle + +/** The register `store` option position: a shared handle or an exclusive factory. */ +// oxlint-disable-next-line typescript/no-explicit-any -- same erased-constraint position as StoreFactory (see above). +export type StoreDecl = StoreHandle | StoreFactory + +/** Normalize a store declaration to its handle type (factories yield their return). */ +export type HandleOf = H extends () => infer R ? R : H + +/** + * Handle-keyed baked actions: the `actions` parameter of an inject factory + * whose registration declared a store — the same baked callback set the + * component receives via {@link PropsStore}. + */ +export type BoundActions = H extends StoreHandle ? BakedActions : never + +/** + * The store props share, derived from the declared handle: a typed selector + * hook plus the baked write set. Components never see the instance itself + * (no update/set — reads via useStore, writes via the declared actions only). + */ +export type PropsStore = H extends StoreHandle + ? { useStore: SnapshotSelectorHook; actions: BakedActions } + : object + +/** + * The defineStore contract (implementation lives beside this declaration, + * bound to the snapshot-store engine): spec in, handle out, with T inferred + * from `init` and the actions table constrained by T. + */ +export type DefineStore = >(spec: StoreSpec) => StoreHandle diff --git a/packages/client/runtime/src/client/contract/store.ts b/packages/client/store/src/index.ts similarity index 85% rename from packages/client/runtime/src/client/contract/store.ts rename to packages/client/store/src/index.ts index 0eb7917b03..09414e95e6 100644 --- a/packages/client/runtime/src/client/contract/store.ts +++ b/packages/client/store/src/index.ts @@ -1,30 +1,27 @@ /** - * Snapshot store engine (zustand vanilla + immer + subscribeWithSelector + + * React-free snapshot store engine (zustand vanilla + immer + subscribeWithSelector + * rafFlush middleware + opt-in persist + dev freeze) plus the declarative * shell over it: {@link defineStore} bakes an init/persist/actions literal * into a {@link StoreHandle}, the registration-side store seat of slot - * terminals. Lives in the React-free runtime (the data layer owns its - * engine; ui-renderer is shell-only React - * glue): engine products are bare observables — subscribe/getSnapshot/ + * terminals. Engine products are bare observables — subscribe/getSnapshot/ * update/set, NO selector hook. Hook synthesis is ui-renderer's (the one * uSES bridge, cached per source at the binding site). */ import { createStore, type StoreApi } from 'zustand/vanilla' import { subscribeWithSelector } from 'zustand/middleware' import { shallow } from 'zustand/shallow' -import { produce } from 'immer' +import { freeze, produce } from 'immer' import type { - ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec, -} from '@deepseek-ai/dsh-client-ui-slots' + ActionsDecl, BakedActions, ObservableSnapshot, StoreHandle, StoreInstance, StoreSpec, +} from './contract.ts' // Store contract types are ui-slots authority; re-exported beside the engine // so store consumers get one import path. export type { - ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec, -} from '@deepseek-ai/dsh-client-ui-slots' - -/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */ -export interface ObservableSnapshot { getSnapshot(): T; subscribe(fn: () => void): () => void } + ActionsDecl, BakedActions, BoundActions, DefineStore, HandleOf, MaybeSnapshotSelectorHook, + ObservableSnapshot, PropsStore, SnapshotSelectorHook, StoreDecl, StoreFactory, + StoreHandle, StoreInstance, StoreSpec, +} from './contract.ts' /** Writable snapshot store (bare data face; React selector hooks are synthesized in ui-renderer). */ export interface SnapshotStore extends ObservableSnapshot { @@ -40,6 +37,26 @@ export interface SnapshotStore extends ObservableSnapshot { set(next: T): void } +/** + * Notify an observer set without allowing one callback to starve the rest. + * @param listeners - current observer callbacks; copied before dispatch. + * @param label - diagnostic owner prefix. + * @param args - callback arguments. + */ +export function notifySubscribers( + listeners: Iterable<(...args: Args) => void>, + label: string, + ...args: Args +): void { + for (const listener of [...listeners]) { + try { + listener(...args) + } catch (error) { + console.error(`${label} subscriber failed:`, error) + } + } +} + /** * Shallow equality for selector slices (zustand/shallow semantics; travels * with the engine so hook consumers need no zustand dependency). @@ -91,10 +108,12 @@ export function createSnapshotStore( const api: StoreApi = createStore()(withSelector) if (opts?.persist) attachPersistence(api, opts.persist.name) - let subscribe = (fn: () => void) => api.subscribe(fn) + let subscribe = (fn: () => void) => api.subscribe(() => { + notifySubscribers([fn], '[client-store]') + }) if (opts?.flush === 'raf') { const listeners = new Set<() => void>() - const flush = rafBatch(() => { for (const fn of [...listeners]) fn() }) + const flush = rafBatch(() => { notifySubscribers(listeners, '[client-store]') }) api.subscribe(flush) subscribe = (fn: () => void) => { listeners.add(fn) @@ -146,19 +165,10 @@ function attachPersistence(api: StoreApi, name: string): void { }) } -/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */ +/** Deep-freeze draftable wholesale-set state outside production: set() bypasses immer's freeze. */ function devFreeze(value: T): T { if (process.env.NODE_ENV === 'production') return value - deepFreeze(value) - return value -} - -function deepFreeze(value: unknown): void { - if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return - Object.freeze(value) - for (const key of Reflect.ownKeys(value)) { - deepFreeze((value as Record)[key]) - } + return freeze(value, true) } // ui-slots owns the contract; this module supplies the engine implementation. diff --git a/packages/client/store/src/invariant.ts b/packages/client/store/src/invariant.ts new file mode 100644 index 0000000000..6c9425d097 --- /dev/null +++ b/packages/client/store/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-store`. + * @module @deepseek-ai/dsh-client-store/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-store' + +/** Cordis companion plugin name. */ +export const name = 'client-store-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package exports a library engine and creates no + * process-global state; each store instance is covered by its owning tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/store/tests/invariant.client.spec.ts b/packages/client/store/tests/invariant.client.spec.ts new file mode 100644 index 0000000000..779422ea82 --- /dev/null +++ b/packages/client/store/tests/invariant.client.spec.ts @@ -0,0 +1,13 @@ +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { describe, expect, it } from 'vitest' +import * as StoreInvariant from '../src/invariant.ts' + +describe('store invariant companion', () => { + it('registers the package-owned empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + + await expect(ctx.plugin(StoreInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/runtime/tests/store.client.spec.ts b/packages/client/store/tests/store.client.spec.ts similarity index 78% rename from packages/client/runtime/tests/store.client.spec.ts rename to packages/client/store/tests/store.client.spec.ts index 3abd0483f2..7ec53a670d 100644 --- a/packages/client/runtime/tests/store.client.spec.ts +++ b/packages/client/store/tests/store.client.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/contract/store.ts' +import { createSnapshotStore, defineStore, shallowEqual } from '../src/index.ts' interface State { a: { n: number } @@ -10,6 +10,8 @@ const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } }) afterEach(() => { vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.restoreAllMocks() }) describe('createSnapshotStore', () => { @@ -90,12 +92,37 @@ describe('createSnapshotStore', () => { expect(() => { (store.getSnapshot().a).n = 9 }).toThrow() }) + it('freezes the owned envelope without traversing opaque object handles', () => { + class OpaqueHandle { + readonly value = { n: 1 } + } + const handle = new OpaqueHandle() + const store = createSnapshotStore({ handle, values: new Set(['x']) }) + + store.set({ handle, values: new Set(['y']) }) + + const snapshot = store.getSnapshot() + expect(Object.isFrozen(snapshot)).toBe(true) + expect(Object.isFrozen(snapshot.handle)).toBe(false) + expect(() => { snapshot.values.add('z') }).toThrow() + }) + it('freezes update produce output outside production (immer dev freeze)', () => { const store = createSnapshotStore(init()) store.update((d) => { d.a.n = 2 }) expect(() => { (store.getSnapshot().a).n = 9 }).toThrow() }) + it('does not deep-freeze wholesale state in production', () => { + vi.stubEnv('NODE_ENV', 'production') + const store = createSnapshotStore(init()) + const next = init() + store.set(next) + + next.a.n = 9 + expect(store.getSnapshot().a.n).toBe(9) + }) + it('rehydrates primitive state whole, not spread into index keys', () => { const backing = new Map() vi.stubGlobal('localStorage', { @@ -122,6 +149,42 @@ describe('createSnapshotStore', () => { const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } }) expect(revived.getSnapshot().a.n).toBe(42) }) + + it('reports rehydration failures without preventing store creation', () => { + const failure = new Error('storage read failed') + vi.stubGlobal('localStorage', { + getItem: () => { throw failure }, + setItem: () => {}, + removeItem: () => {}, + }) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const store = createSnapshotStore(init(), { persist: { name: 'spec-broken-read' } }) + + expect(store.getSnapshot()).toEqual(init()) + expect(report).toHaveBeenCalledWith( + "snapshot store 'spec-broken-read' rehydration failed:", + failure, + ) + }) + + it('reports persistence failures without rejecting the write', () => { + const failure = new Error('storage write failed') + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: () => { throw failure }, + removeItem: () => {}, + }) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + const store = createSnapshotStore(init(), { persist: { name: 'spec-broken-write' } }) + + expect(() => { store.update((draft) => { draft.a.n = 7 }) }).not.toThrow() + expect(store.getSnapshot().a.n).toBe(7) + expect(report).toHaveBeenCalledWith( + "snapshot store 'spec-broken-write' persistence failed:", + failure, + ) + }) }) describe('defineStore', () => { @@ -136,12 +199,17 @@ describe('defineStore', () => { it('create() yields a live instance: fresh init state, selector-visible action writes', () => { const inst = declare().create() - expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' }) + expect(inst.getSnapshot()).toEqual({ selection: null, draft: '' }) + const listener = vi.fn() + const unsubscribe = inst.subscribe(listener) inst.actions.setDraft('hello') inst.actions.select('m1') expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' }) + expect(listener).toHaveBeenCalledTimes(2) + unsubscribe() inst.actions.clearDraft() expect(inst.store.getSnapshot().draft).toBe('') + expect(listener).toHaveBeenCalledTimes(2) }) it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => { diff --git a/packages/client/store/tsconfig.json b/packages/client/store/tsconfig.json new file mode 100644 index 0000000000..66c8817e0e --- /dev/null +++ b/packages/client/store/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/client/store/tsdown.config.ts b/packages/client/store/tsdown.config.ts new file mode 100644 index 0000000000..d6757c05f8 --- /dev/null +++ b/packages/client/store/tsdown.config.ts @@ -0,0 +1,6 @@ +import { staticLinked } from '../tsdown.client.ts' + +export default staticLinked( + '@deepseek-ai/dsh-client-store', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index e7bb8e1cce..4199ec7226 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -32,11 +32,13 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-api-remotes" ], "platform": "web" @@ -50,27 +52,36 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 3e98310cca..06448f27a1 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -9,7 +9,7 @@ */ import { useEffect } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx index eab363122c..d2338596ba 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useState } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentPresetSettingsState } from './settings-store.ts' import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index f7350076c2..cf71faf9d7 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -13,7 +13,7 @@ */ import { useEffect, useState } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index ed7f9c24b8..59ccb19226 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -15,7 +15,7 @@ import type { ReactNode } from 'react' import { Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index e28039fe45..d40b14ae00 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -12,6 +12,8 @@ */ import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: pulls the Session Controller service merge (ctx.sessions). +import type {} from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the ctx.remote merge and the forwarded-event key face @@ -19,7 +21,10 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: pulls the Workspace UI navigation service merge (ctx.uiWorkspace). +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import { AgentPresetLabel } from './AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx' import { AgentPresetRow } from './AgentPresetRow.tsx' @@ -99,7 +104,7 @@ export function apply(ctx: ClientContext): void { // The new-session chip and the header label: one controller, because the // staged choice belongs to the flow rather than to any one session. - ctx.inject(['slots', 'conversation', 'sessions', 'workspaces'], (scope: ClientContext) => { + ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => { const api = (scope.get('connection') as ConnectionHandle).api const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => { const state = scope.sessions.list.getSnapshot() @@ -160,7 +165,7 @@ export function apply(ctx: ClientContext): void { // The introduce cue makes the chip announce the pick the user never // made on this screen — the stage happened back in settings. seat.stage('cordis', true) - scope.workspaces.startSession() + scope.uiWorkspace.startSession() } const chip = scope.slots.register({ name: 'conversation.hero.agentPreset', diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index c91e77bbd5..64e71fefb7 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -11,9 +11,8 @@ */ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import { - createSnapshotStore, type SessionId, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { messageOf, presetOptions } from './settings-store.ts' import type { AgentPresetOption } from './settings-store.ts' diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 0499d24a3b..a4af289990 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -15,7 +15,7 @@ */ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' /** Ids a preset directory may be named, mirroring the host's own rule. */ diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index cf770151bd..d0a89f358b 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -8,7 +8,7 @@ */ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** The agent-preset settings namespace on the host wire. */ diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 835f8cf0b8..e93570355e 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -80,9 +80,7 @@ async function bench() { const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - // The plugins inject `remote`; forwarded events reach them through the - // same `$dispatch` handoff the connection sink makes. - new TestRemote(ctx) + const remote = new TestRemote(ctx) const calls: string[] = [] ctx.provide('connection', { api: { @@ -120,7 +118,7 @@ async function bench() { }, } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault } + return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote } } function declareRoot(slots: SlotRegistry): () => void { @@ -145,8 +143,8 @@ function declareConversation(slots: SlotRegistry): () => void { } as never, () => null) } -/** A workspaces double recording new-session starts. */ -function workspacesDouble() { +/** A Workspace UI double recording new-session starts. */ +function uiWorkspaceDouble() { const starts: unknown[] = [] return { starts, @@ -255,18 +253,18 @@ describe('ui-agent-preset apply', () => { }) it('refreshes a showing surface when its namespace changes, and ignores others', async () => { - const { ctx, slots, calls } = await bench() + const { ctx, slots, calls, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() await section.load() const before = calls.length - ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1]) + remote.emit('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBe(before + 2) }) const afterRelevant = calls.length - ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() // Both surfaces re-read on their own namespace; an unrelated one moves @@ -289,12 +287,12 @@ describe('ui-agent-preset apply', () => { }) it('leaves the section alone until it has been opened once', async () => { - const { ctx, slots, calls } = await bench() + const { ctx, slots, calls, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const before = calls.length - ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1]) + remote.emit('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) }) // Only the General row reloads: a section nobody opened has nothing to @@ -308,8 +306,8 @@ describe('ui-agent-preset apply', () => { const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - ctx.provide('workspaces', workspacesDouble() as never) - const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }) + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }) await fiber.await() const chip = slots.entries('conversation.hero.agentPreset')[0]! @@ -325,13 +323,13 @@ describe('ui-agent-preset apply', () => { }) it('moves the chip when the default changes on the settings surface', async () => { - const { ctx, slots, moveDefault } = await bench() + const { ctx, slots, moveDefault, remote } = await bench() declareRoot(slots) const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - ctx.provide('workspaces', workspacesDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = slots.entries('conversation.hero.agentPreset')[0]! const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() @@ -345,11 +343,11 @@ describe('ui-agent-preset apply', () => { // An unrelated namespace moves nothing: the chip re-reads on its own // setting, not on every settings write in the process. moveDefault() - ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') - ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1]) + remote.emit('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal') }) @@ -357,7 +355,7 @@ describe('ui-agent-preset apply', () => { }) it('folds a remote preset commit into the shared session row', async () => { - const { ctx, slots } = await bench() + const { ctx, slots, remote } = await bench() declareRoot(slots) declareConversation(slots) ctx.provide('conversation', {} as never) @@ -366,10 +364,10 @@ describe('ui-agent-preset apply', () => { byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } }, } ctx.provide('sessions', sessionsDouble(state) as never) - ctx.provide('workspaces', workspacesDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() - ctx.remote.$dispatch('agent-preset/selected', ['s1', 'minimal']) + remote.emit('agent-preset/selected', ['s1', 'minimal']) expect(state.byId.s1.agentPreset).toBe('minimal') }) @@ -380,8 +378,8 @@ describe('ui-agent-preset apply', () => { const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - ctx.provide('workspaces', workspacesDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = slots.entries('conversation.hero.agentPreset')[0]! const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() @@ -415,8 +413,8 @@ describe('ui-agent-preset apply', () => { } = { byId: {} } const sessions = sessionsDouble(state) ctx.provide('sessions', sessions as never) - ctx.provide('workspaces', workspacesDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() @@ -443,8 +441,8 @@ describe('ui-agent-preset apply', () => { byId: { s1: { id: 's1', blank: true } }, }) ctx.provide('sessions', sessions as never) - ctx.provide('workspaces', workspacesDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() @@ -467,8 +465,8 @@ describe('ui-agent-preset apply', () => { } const sessions = sessionsDouble(state) ctx.provide('sessions', sessions as never) - ctx.provide('workspaces', workspacesDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const chip = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() @@ -490,8 +488,8 @@ describe('ui-agent-preset apply', () => { declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - ctx.provide('workspaces', workspacesDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const label = (slots.entries('conversation.session.header.actions')[0]! .inject as unknown as () => AgentPresetLabelInjected)() const row = (slots.entries('settings.general.item')[0]! @@ -511,9 +509,9 @@ describe('ui-agent-preset apply', () => { const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) ctx.provide('sessions', sessionsDouble({ byId: {} }) as never) - const workspaces = workspacesDouble() - ctx.provide('workspaces', workspaces as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + const uiWorkspace = uiWorkspaceDouble() + ctx.provide('uiWorkspace', uiWorkspace as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() const seat = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() @@ -525,7 +523,7 @@ describe('ui-agent-preset apply', () => { // new-session flow began. expect(section.startCreatorDraft).toBeDefined() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(workspaces.starts).toHaveLength(1) + expect(uiWorkspace.starts).toHaveLength(1) // A cross-screen stage carries the introduce cue; the chip acknowledges // it once, and a repeat acknowledgement leaves the snapshot untouched. @@ -549,8 +547,8 @@ describe('ui-agent-preset apply', () => { } = { byId: {} } const sessions = sessionsDouble(state) ctx.provide('sessions', sessions as never) - ctx.provide('workspaces', workspacesDouble() as never) - await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + ctx.provide('uiWorkspace', uiWorkspaceDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() const seat = (slots.entries('conversation.hero.agentPreset')[0]! .inject as unknown as () => AgentPresetSeatInjected)() diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index b7fc7c2dec..29ecc33eb5 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -10,7 +10,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx' import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 3fdea20bfe..7a829df10b 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -9,7 +9,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' import type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts' diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 901f3f6c8a..0488aba5ee 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../../test-support/client-runtime" @@ -26,6 +26,15 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-workspace" + }, { "path": "../ui-settings" }, @@ -37,6 +46,12 @@ }, { "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../core/session" } ] } diff --git a/packages/client/ui-approval/README.i18n.yaml b/packages/client/ui-approval/README.i18n.yaml new file mode 100644 index 0000000000..8158657bd3 --- /dev/null +++ b/packages/client/ui-approval/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-approval/README.md +README.md: efc3a81ad88d94b2835b32a7663f72c2e95c1614 +README.zh.md: 8eaa561b9cbdffd0998b568109497c0e9587f043 diff --git a/packages/client/ui-approval/README.md b/packages/client/ui-approval/README.md new file mode 100644 index 0000000000..efc3a81ad8 --- /dev/null +++ b/packages/client/ui-approval/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-approval + +English | [中文](README.zh.md) + +Browser approval presentation over the Agent-scoped Remote Event waterfall. The plugin publishes each pending request through `ctx.uiSession`, takes over the Conversation composer, optionally renders correlated Tool detail, and returns the user's decision to the waiting Host request. + +## Model Experience + +None, as this package presents approval requests in the browser and registers nothing model-facing. + +#### KV Cache effect + +None; approval request and response rendering does not alter a model request. + +## Known Limitations and Deferred Work + +- **The panel exposes transient decisions only** — it supports allow-once and reject; persistent permission policy remains owned by Host-side approval packages. diff --git a/packages/client/ui-approval/README.zh.md b/packages/client/ui-approval/README.zh.md new file mode 100644 index 0000000000..8eaa561b9c --- /dev/null +++ b/packages/client/ui-approval/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-approval + +[English](README.md) | 中文 + +基于 Agent-scoped Remote Event waterfall 的浏览器审批界面。插件通过 `ctx.uiSession` 发布每个待处理请求、接管 Conversation composer、按需渲染关联的 Tool 详情,并将用户决定返回给等待中的 Host 请求。 + +## 模型体验 + +无,因为本包只在浏览器中呈现审批请求,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;审批请求和响应的呈现不会改变模型请求。 + +## 已知限制与暂缓事项 + +- **面板只提供临时决定**——它支持仅本次允许和拒绝;持久权限策略仍由 Host 侧审批 package 拥有。 diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json new file mode 100644 index 0000000000..649b855553 --- /dev/null +++ b/packages/client/ui-approval/package.json @@ -0,0 +1,90 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-approval", + "description": "Approval composer takeover over the scoped Remote Event waterfall", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-approval" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client", + "@deepseek-ai/dsh-client-ui-conversation/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css new file mode 100644 index 0000000000..12ea9bb631 --- /dev/null +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -0,0 +1,74 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px; +} + +.card { + overflow: hidden; + width: 100%; + max-width: var(--dsh-chat-content-width); + border: 1px solid var(--dsw-alias-state-warn-secondary); + border-radius: 20px; + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-shadow-lv2); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.strip { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-primary); + font-size: 13px; + line-height: 18px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--dsw-alias-state-warn-primary); +} + +.body { + display: flex; + flex-direction: column; + gap: 6px; + box-sizing: border-box; + max-height: var(--dsh-composer-text-max-height); + overflow-y: auto; + padding: 12px 16px 0; +} + +.headline { + color: var(--dsw-alias-label-primary); + font-size: 15px; + font-weight: 500; + line-height: 24px; +} + +.command { + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 20px; + word-break: break-all; +} + +.actionRow { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 16px; +} + +.reject:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); + border-color: transparent; +} diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.tsx b/packages/client/ui-approval/src/client/ApprovalPanel.tsx new file mode 100644 index 0000000000..dca3692899 --- /dev/null +++ b/packages/client/ui-approval/src/client/ApprovalPanel.tsx @@ -0,0 +1,55 @@ +/** Composer takeover for one pending approval waterfall. */ +import { useState, type ReactNode } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts' +import css from './ApprovalPanel.module.css' + +/** + * Render one pending approval and its optional Tool-owned detail. + * @param props - selector-matched request and standard Slot props. + * @returns The approval composer takeover. + */ +export function ApprovalPanel(props: ApprovalComposerProps) { + const approval = props.matched + const detail = approval.callId === undefined + ? null + : props.renderSlot('conversation.approval.detail', { callId: approval.callId }) + return +} + +function ApprovalFlow({ pending, detail, t }: { + pending: PendingApproval + detail: ReactNode + t: ApprovalComposerProps['t'] +}) { + const [answered, setAnswered] = useState(false) + const answer = (outcome: 'allowed-once' | 'rejected'): void => { + setAnswered(true) + void pending.answer(outcome).catch(() => { setAnswered(false) }) + } + return ( +

+
+
{t('waiting')}
+
+
{pending.reason ?? t('escalation', { toolName: pending.toolName })}
+ {detail !== null &&
{detail}
} +
+
+ + +
+
+
+ ) +} diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts new file mode 100644 index 0000000000..0c12b119e7 --- /dev/null +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -0,0 +1,154 @@ +/** Approval composer and optional correlated-detail contracts. */ +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + PropsLocale, PropsRenderSlots, PropsRuntime, +} from '@deepseek-ai/dsh-client-ui-slots' +import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ApprovalKey } from '../locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-session/client' { + interface SessionPendingInteractionMap { + /** Pending approval request. */ + approval: PendingApproval + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Approval prompt copy. */ + approval: ApprovalKey + } + + interface SlotMap { + /** Optional detail for the Tool call correlated with an approval request. */ + 'conversation.approval.detail': { + kind: 'single' + scope: 'session' + owner: ApprovalDetailOwnerProps + } + } +} + +/** Stable identity handed to an optional approval-detail renderer. */ +export interface ApprovalDetailOwnerProps { + /** Tool call correlated with the request. */ + callId: CallId +} + +/** Client-visible fields of an approval request projected through Remote Events. */ +export interface ApprovalPresentationRequest { + /** Tool requesting the decision. */ + readonly toolName: string + /** Tool call correlated with the request. */ + readonly callId?: CallId + /** Human-readable reason supplied by the requester. */ + readonly reason?: string + /** Cancellation projected from the Host waterfall. */ + readonly signal?: AbortSignal +} + +/** Decisions this interactive Client presentation can return. */ +export type ApprovalDecision = 'allowed-once' | 'rejected' + +let nextApprovalKey = 0 + +/** One answerable Client presentation of a pending Host waterfall. */ +export class PendingApproval { + /** Domain discriminator used by Session pending-interaction consumers. */ + readonly kind = 'approval' as const + /** Opaque render identity and one-shot remount axis. */ + readonly key: string + /** Tool requesting the decision. */ + readonly toolName: string + /** Correlated Tool call, when supplied by the asker. */ + readonly callId: CallId | undefined + /** Human-readable reason supplied by the asker. */ + readonly reason: string | undefined + /** Result returned by the Remote Event listener to the Host waterfall. */ + readonly result: Promise + + readonly #resolve: (outcome: ApprovalDecision) => void + readonly #reject: (reason: unknown) => void + readonly #signal: AbortSignal | undefined + readonly #onAbort: (() => void) | undefined + readonly #delegated = Symbol('pending approval delegated') + #settled = false + + /** + * @param sessionId - Agent/Session identity owning the scoped request. + * @param request - Host approval request projected through the Remote Event. + */ + constructor(readonly sessionId: SessionId, request: ApprovalPresentationRequest) { + nextApprovalKey += 1 + this.key = `approval:${String(nextApprovalKey)}` + this.toolName = request.toolName + this.callId = request.callId + this.reason = request.reason + const completion = Promise.withResolvers() + this.result = completion.promise + this.#resolve = completion.resolve + this.#reject = completion.reject + this.#signal = request.signal + if (request.signal === undefined) { + this.#onAbort = undefined + return + } + const onAbort = (): void => { + this.abort(request.signal?.reason ?? new Error('approval request was aborted')) + } + this.#onAbort = onAbort + request.signal.addEventListener('abort', onAbort, { once: true }) + if (request.signal.aborted) onAbort() + } + + /** + * Resolve the Host waterfall with the user's decision. + * @param outcome - supported interactive decision. + */ + answer(outcome: ApprovalDecision): Promise { + return settlePendingComposer(() => { + this.finish(() => { this.#resolve(outcome) }) + }, 'pending approval settlement failed') + } + + /** Delegate an unanswered request to the next waterfall listener. */ + delegate(): void { + if (this.#settled) return + this.finish(() => { this.#reject(this.#delegated) }) + } + + /** + * Test whether a rejection requests waterfall delegation. + * @param reason - rejection received from {@link PendingApproval.result}. + * @returns whether {@link PendingApproval.delegate} produced it. + */ + isDelegation(reason: unknown): boolean { + return reason === this.#delegated + } + + /** + * End an unanswered presentation when its transport, scope, or plugin lifetime ends. + * @param reason - rejection exposed to the waiting Remote Event listener. + */ + abort(reason: unknown): void { + if (this.#settled) return + this.finish(() => { this.#reject(reason) }) + } + + private finish(settle: () => void): void { + if (this.#settled) throw new Error(`pending approval ${this.key} is already settled`) + this.#settled = true + if (this.#signal !== undefined && this.#onAbort !== undefined) { + this.#signal.removeEventListener('abort', this.#onAbort) + } + settle() + } +} + +/** Full props of the approval composer takeover. */ +export type ApprovalComposerProps = + PropsRuntime<'conversation.composer'> + & PropsRenderSlots<'conversation.approval.detail'> + & { matched: PendingApproval } + & PropsLocale<'approval'> diff --git a/packages/client/ui-approval/src/client/index.ts b/packages/client/ui-approval/src/client/index.ts new file mode 100644 index 0000000000..cc044f4fc1 --- /dev/null +++ b/packages/client/ui-approval/src/client/index.ts @@ -0,0 +1,93 @@ +/** Browser approval consumer over the existing scoped Remote Event waterfall. */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { PendingInteractionPublisher } from '@deepseek-ai/dsh-client-ui-session/client' +import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ApprovalPanel } from './ApprovalPanel.tsx' +import { PendingApproval } from './contract/slots.ts' +import { en, zh } from './locales.ts' + +export type { + ApprovalComposerProps, + ApprovalDecision, + ApprovalDetailOwnerProps, + ApprovalPresentationRequest, + PendingApproval, +} from './contract/slots.ts' +export type { ApprovalKey } from './locales.ts' + +/** Required services: Agent scopes, Remote Events, Session UI, Slot registry, and copy. */ +export const inject = ['sessions', 'remote', 'uiSession', 'slots', 'locale'] + +const NS = 'approval' + +type ApprovalListener = TypertClientEventListener<'approval/request'> +type ClientApprovalRequest = Parameters[0] +type ClientApprovalNext = Parameters[1] +type ClientApprovalOutcome = Awaited> + +/* jscpd:ignore-start -- Approval and Question intentionally mirror one Remote waterfall lifecycle. */ +/** Present one request until the user answers or its lifetime ends. */ +async function answerApproval( + ctx: ClientContext, + owner: ClientContext, + request: ClientApprovalRequest, + next: ClientApprovalNext, + registerPendingInteraction: PendingInteractionPublisher, +): Promise { + const sessionId = ctx.sessions.scopeOf(owner) + if (sessionId === undefined) return next() + const pending = new PendingApproval(sessionId, { + toolName: request.toolName, + ...(request.callId === undefined + ? {} + : { callId: request.callId }), + ...(request.reason === undefined ? {} : { reason: request.reason }), + ...(request.signal === undefined ? {} : { signal: request.signal }), + }) + const completed = Promise.withResolvers() + const remove = registerPendingInteraction(pending, async () => { + pending.delegate() + await completed.promise + }) + try { + try { + return await pending.result + } catch (error) { + if (pending.isDelegation(error)) return await next() + throw error + } + } finally { + remove() + completed.resolve() + } +} +/* jscpd:ignore-end */ + +/** + * Install approval copy and the scoped waterfall consumer. + * @param ctx - Client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-approval: dictionaries') + const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( + () => 0, + ) + ctx.slots.inject('conversation.composer', () => ctx.slots.register({ + name: 'conversation.composer', + priority: 1, + select: ({ pendingInteraction }: ComposerChainProps): PendingApproval | null => + pendingInteraction instanceof PendingApproval ? pendingInteraction : null, + locale: NS, + children: { + 'conversation.approval.detail': { kind: 'single', scope: 'session' }, + }, + }, ApprovalPanel)) + ctx.remote.$on('approval/request', function (request, next) { + return answerApproval(ctx, this, request, next, registerPendingInteraction) + }) +} diff --git a/packages/client/ui-approval/src/client/locales.ts b/packages/client/ui-approval/src/client/locales.ts new file mode 100644 index 0000000000..e28f2ce74a --- /dev/null +++ b/packages/client/ui-approval/src/client/locales.ts @@ -0,0 +1,22 @@ +/** `approval` namespace dictionaries. */ + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + waiting: '等待审批', + 'detail.aria': '审批详情', + escalation: '工具 {toolName} 请求越权执行', + reject: '拒绝', + allowOnce: '允许一次', +} satisfies Record + +/** Approval dictionary key union. */ +export type ApprovalKey = keyof typeof zh + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + waiting: 'Waiting for approval', + 'detail.aria': 'Approval details', + escalation: 'Tool {toolName} requests privileged execution', + reject: 'Reject', + allowOnce: 'Allow once', +} satisfies Record diff --git a/packages/client/ui-approval/src/css-modules.d.ts b/packages/client/ui-approval/src/css-modules.d.ts new file mode 100644 index 0000000000..24a27bda3f --- /dev/null +++ b/packages/client/ui-approval/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Readonly> + export default classes +} diff --git a/packages/client/ui-approval/src/index.ts b/packages/client/ui-approval/src/index.ts new file mode 100644 index 0000000000..f7a2e2b689 --- /dev/null +++ b/packages/client/ui-approval/src/index.ts @@ -0,0 +1,4 @@ +/** Browser-only approval presentation plugin; the Host capability is composed independently. */ + +/** Node plugin body. */ +export function apply(): void {} diff --git a/packages/client/ui-approval/src/invariant.ts b/packages/client/ui-approval/src/invariant.ts new file mode 100644 index 0000000000..833270df30 --- /dev/null +++ b/packages/client/ui-approval/src/invariant.ts @@ -0,0 +1,23 @@ +/** Package-owned invariant companion for the approval presentation plugin. */ +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-approval' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-approval-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: registries own and observe the Remote listener and temporary Slot entry. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns The installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx new file mode 100644 index 0000000000..665ae928a4 --- /dev/null +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -0,0 +1,386 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx' +import type { ApprovalComposerProps } from '../src/client/contract/slots.ts' +import { PendingApproval } from '../src/client/contract/slots.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' +import * as ApprovalInvariant from '../src/invariant.ts' + +type ApprovalListener = ( + this: Context, + request: { + toolName: string + callId?: string + reason?: string + signal?: AbortSignal + }, + next: () => Promise<'unavailable'>, +) => Promise + +interface PluginBench { + readonly ctx: Context + readonly listener: ApprovalListener + readonly pending: { getSnapshot(): readonly PendingApproval[] } + readonly registerPendingInteraction: ReturnType + readonly disposeSlot: ReturnType + readonly disposeLocale: ReturnType + readonly register: ReturnType + readonly injectSlot: ReturnType + releasePending(): Promise + registration(): { + options: { + select(props: { pendingInteraction: PendingApproval | undefined }): PendingApproval | null + } + component: unknown + } +} + +function setupPlugin(): PluginBench { + const ctx = new Context() + let listener: ApprovalListener | undefined + let registration: { + options: { + select(props: { pendingInteraction: PendingApproval | undefined }): PendingApproval | null + } + component: unknown + } | undefined + const disposeSlot = vi.fn() + const disposeLocale = vi.fn() + const pending = new Map Promise>() + const registerPendingInteraction = vi.fn((_precedence: (value: PendingApproval) => number) => ( + value: PendingApproval, + delegate: () => Promise, + ) => { + _precedence(value) + pending.set(value, delegate) + return () => { pending.delete(value) } + }) + const register = vi.fn(( + options: NonNullable['options'], + component: unknown, + ) => { + registration = { options, component } + return disposeSlot + }) + const injectSlot = vi.fn((_name: string, mount: () => () => void) => { + const dispose = ctx.effect(() => mount()) + return () => { void dispose() } + }) + ctx.provide('remote', { + $on: (_event: string, callback: ApprovalListener) => { + listener = callback + return () => {} + }, + } as never) + ctx.provide('sessions', { scopeOf } as never) + ctx.provide('uiSession', { registerPendingInteraction } as never) + ctx.provide('slots', { inject: injectSlot, register } as never) + ctx.provide('locale', { + register: vi.fn(() => disposeLocale), + } as never) + + apply(ctx) + if (listener === undefined) throw new Error('approval listener was not registered') + return { + ctx, + listener, + pending: { getSnapshot: () => [...pending.keys()] }, + registerPendingInteraction, + disposeSlot, + disposeLocale, + register, + injectSlot, + async releasePending() { + const delegates = [...pending.values()] + pending.clear() + await Promise.allSettled(delegates.map(delegate => delegate())) + }, + registration: () => { + if (registration === undefined) throw new Error('approval slot was not registered') + return registration + }, + } +} + +const id = (value: string): SessionId => value as SessionId + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('PendingApproval', () => { + it('resolves once, removes its abort listener, and ignores later abort cleanup', async () => { + const controller = new AbortController() + const remove = vi.spyOn(controller.signal, 'removeEventListener') + const pending = new PendingApproval(id('s1'), { + toolName: 'bash', + callId: 'call-1' as CallId, + reason: 'needs access', + signal: controller.signal, + }) + + await pending.answer('allowed-once') + + await expect(pending.result).resolves.toBe('allowed-once') + expect(pending.sessionId).toBe(id('s1')) + expect(pending.toolName).toBe('bash') + expect(pending.callId).toBe('call-1') + expect(pending.reason).toBe('needs access') + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(() => { pending.abort(new Error('late')) }).not.toThrow() + expect(() => { pending.delegate() }).not.toThrow() + await expect(pending.answer('rejected')).rejects.toThrow(/already settled/) + }) + + it('rejects with an already-aborted signal reason', async () => { + const controller = new AbortController() + const reason = new Error('host cancelled') + controller.abort(reason) + + const pending = new PendingApproval(id('s1'), { + toolName: 'read', + signal: controller.signal, + }) + + await expect(pending.result).rejects.toBe(reason) + }) + + it('uses a stable fallback when an abort signal supplies no reason', async () => { + const signal = { + aborted: true, + reason: undefined, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as AbortSignal + + const pending = new PendingApproval(id('s1'), { toolName: 'read', signal }) + + await expect(pending.result).rejects.toThrow('approval request was aborted') + }) + + it('rejects an unanswered request explicitly without an AbortSignal', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'write' }) + const reason = new Error('scope released') + + pending.abort(reason) + + await expect(pending.result).rejects.toBe(reason) + }) + + it('wraps a non-Error answer settlement failure with its cause', async () => { + const failure = 'resolve failed' + const completion = Promise.withResolvers<'allowed-once' | 'rejected'>() + const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({ + promise: completion.promise, + resolve: () => { throw failure }, + reject: completion.reject, + })) + const pending = new PendingApproval(id('s1'), { toolName: 'write' }) + withResolvers.mockRestore() + + const settlement = await pending.answer('allowed-once').catch((error: unknown) => error) + + expect(settlement).toBeInstanceOf(Error) + expect(settlement).toMatchObject({ + message: 'pending approval settlement failed', + cause: failure, + }) + completion.resolve('allowed-once') + await expect(pending.result).resolves.toBe('allowed-once') + }) +}) + +describe('approval Remote Event consumer', () => { + it('delegates an event that has no Agent scope', async () => { + const bench = setupPlugin() + const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) + + await expect(bench.listener.call(bench.ctx, { toolName: 'bash' }, next)) + .resolves.toBe('unavailable') + expect(next).toHaveBeenCalledOnce() + expect(bench.pending.getSnapshot()).toEqual([]) + expect(bench.register).toHaveBeenCalledOnce() + }) + + it('publishes one scoped takeover, returns the answer, and keeps stable registrations', async () => { + const bench = setupPlugin() + const scope = createScope(bench.ctx, id('s1')) + await scope.fiber.await() + const controller = new AbortController() + const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) + const result = bench.listener.call(scope.ctx, { + toolName: 'bash', + callId: 'call-1', + reason: 'needs access', + signal: controller.signal, + }, next) + const pending = bench.pending.getSnapshot()[0]! + const { options, component } = bench.registration() + + expect(component).toBe(ApprovalPanel) + expect(options.select({ pendingInteraction: undefined })).toBeNull() + expect(options.select({ pendingInteraction: pending })).toBe(pending) + expect(pending).toMatchObject({ + kind: 'approval', + sessionId: id('s1'), + toolName: 'bash', + callId: 'call-1', + reason: 'needs access', + }) + + await pending.answer('allowed-once') + + await expect(result).resolves.toBe('allowed-once') + expect(next).not.toHaveBeenCalled() + expect(bench.pending.getSnapshot()).toEqual([]) + expect(bench.register).toHaveBeenCalledOnce() + expect(bench.disposeSlot).not.toHaveBeenCalled() + await scope.fiber.dispose() + }) + + it('propagates request cancellation after removing the pending object', async () => { + const bench = setupPlugin() + const scope = createScope(bench.ctx, id('s1')) + await scope.fiber.await() + const controller = new AbortController() + const reason = new Error('cancelled by host') + const result = bench.listener.call(scope.ctx, { + toolName: 'bash', + signal: controller.signal, + }, () => Promise.resolve('unavailable')) + expect(bench.pending.getSnapshot()).toHaveLength(1) + + controller.abort(reason) + + await expect(result).rejects.toBe(reason) + expect(bench.pending.getSnapshot()).toEqual([]) + expect(bench.disposeSlot).not.toHaveBeenCalled() + await scope.fiber.dispose() + }) + + it('delegates an active request when its interaction domain unloads', async () => { + const bench = setupPlugin() + const scope = createScope(bench.ctx, id('s1')) + await scope.fiber.await() + const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) + const result = bench.listener.call(scope.ctx, { toolName: 'bash' }, next) + expect(bench.pending.getSnapshot()).toHaveLength(1) + + await bench.releasePending() + + await expect(result).resolves.toBe('unavailable') + expect(next).toHaveBeenCalledOnce() + expect(bench.pending.getSnapshot()).toEqual([]) + await scope.fiber.dispose() + }) + + it('publishes a scoped request without optional request metadata', async () => { + const bench = setupPlugin() + const scope = createScope(bench.ctx, id('s1')) + await scope.fiber.await() + const result = bench.listener.call(scope.ctx, { toolName: 'read' }, () => Promise.resolve('unavailable')) + const pending = bench.pending.getSnapshot()[0]! + + await pending.answer('rejected') + + await expect(result).resolves.toBe('rejected') + expect(pending).toMatchObject({ toolName: 'read' }) + expect(pending.callId).toBeUndefined() + expect(pending.reason).toBeUndefined() + await scope.fiber.dispose() + }) + + it('removes stable registrations with the plugin lifetime', async () => { + const bench = setupPlugin() + await bench.ctx.fiber.dispose() + expect(bench.disposeSlot).toHaveBeenCalledOnce() + expect(bench.disposeLocale).toHaveBeenCalledOnce() + }) +}) + +function panelProps( + pending: PendingApproval, + renderSlot: ApprovalComposerProps['renderSlot'] = vi.fn(() => null), +): ApprovalComposerProps { + const messages: Record = { + waiting: 'Waiting', + 'detail.aria': 'Approval details', + escalation: `Tool ${pending.toolName} asks`, + reject: 'Reject', + allowOnce: 'Allow once', + } + return { + matched: pending, + renderSlot, + t: (key: string) => messages[key] ?? key, + } as unknown as ApprovalComposerProps +} + +describe('ApprovalPanel', () => { + it('renders fallback copy without detail and returns rejection', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + const props = panelProps(pending) + render() + + expect(screen.getByText('Tool bash asks')).toBeTruthy() + expect(screen.getByRole('group', { name: 'Approval details' })).toBeTruthy() + expect(props.renderSlot).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + + await expect(pending.result).resolves.toBe('rejected') + expect(screen.getByRole('button', { name: 'Reject' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(true) + }) + + it('renders correlated detail and returns allow-once', async () => { + const pending = new PendingApproval(id('s1'), { + toolName: 'bash', + callId: 'call-1' as CallId, + reason: 'Run this exact command', + }) + const renderSlot = vi.fn(() => pnpm test) + render() + + expect(screen.getByText('Run this exact command')).toBeTruthy() + expect(screen.getByText('pnpm test')).toBeTruthy() + expect(renderSlot).toHaveBeenCalledWith('conversation.approval.detail', { + callId: 'call-1', + }) + fireEvent.click(screen.getByRole('button', { name: 'Allow once' })) + + await expect(pending.result).resolves.toBe('allowed-once') + }) + + it('re-enables actions when answering fails', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + vi.spyOn(pending, 'answer').mockRejectedValue(new Error('transport closed')) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Allow once' })) + expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(true) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(false) + }) + pending.abort(new Error('test cleanup')) + await pending.result.catch(() => {}) + }) +}) + +describe('package entries', () => { + it('declares its service edges, keeps the Host half inert, and registers its invariant', async () => { + expect(inject).toEqual(['sessions', 'remote', 'uiSession', 'slots', 'locale']) + expect(() => { nodeApply() }).not.toThrow() + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + + await expect(ctx.plugin(ApprovalInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-approval/tsconfig.json b/packages/client/ui-approval/tsconfig.json new file mode 100644 index 0000000000..f7b719a444 --- /dev/null +++ b/packages/client/ui-approval/tsconfig.json @@ -0,0 +1,48 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../locale" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-approval/tsdown.config.ts b/packages/client/ui-approval/tsdown.config.ts new file mode 100644 index 0000000000..f7e798c609 --- /dev/null +++ b/packages/client/ui-approval/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-approval', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 9f25dc5421..71ba3edf76 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -32,7 +32,9 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" } @@ -50,8 +52,9 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "react": "^18.2.0", @@ -67,8 +70,9 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^" } } diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 0d0dac02f8..c84914db54 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -1,4 +1,4 @@ -import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 616e9c7292..bcdbfb7adf 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -1,6 +1,8 @@ /** Browser attachment plugin: fills conversation's composer and message-image slots. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { ComposerAttachments } from './ComposerAttachments.tsx' import { MessageImages } from './MessageImages.tsx' diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 62f4887cdd..d7d37bfd97 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' +import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import { ImageGallery, MessageImage } from '../src/MessageImage.tsx' import type { MessageImageLabels } from '../src/MessageImage.tsx' import { MessageImages } from '../src/client/MessageImages.tsx' @@ -28,6 +29,23 @@ const attachment = { name: 'history.png', } +type AttentionSnapshot = Parameters[0]>[0] +type TrajectorySnapshot = Parameters[0]>[0] + +const noAttention: AttentionSnapshot = new Map() +const emptyTrajectory: TrajectorySnapshot = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} +const useSessionPendingInteraction: MessageImagesProps['useSessionPendingInteraction'] = selector => selector(noAttention) +const useConversation: MessageImagesProps['useConversation'] = selector => selector(EMPTY_CONVERSATION_SNAPSHOT) +const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_SNAPSHOT) +const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory) + describe('MessageImage', () => { it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') @@ -190,8 +208,12 @@ describe('ImageGallery', () => { sessionId: 'message-images-test' as MessageImagesProps['sessionId'], useSession, useSessions, + useSessionPendingInteraction, useWorkspaces, useProjection: () => undefined, + useConversation, + useChat, + useTrajectory, useInput, inputActions: { setDraft: vi.fn(), diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index 4b2ff047e8..9ca742377f 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -1,6 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as applyHost } from '../src/index.ts' import { apply, inject } from '../src/client/index.ts' import { ComposerAttachments } from '../src/client/ComposerAttachments.tsx' diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 2f5cd3a73e..0cd20ee366 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -15,7 +15,10 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../runtime" + "path": "../ui-renderer" + }, + { + "path": "../ui-chat" }, { "path": "../ui-conversation" diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index e9cf326b3f..33180942fe 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -32,8 +32,8 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-sidebar" ], "platform": "web" @@ -45,16 +45,16 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-brand-official/src/client/index.ts b/packages/client/ui-brand-official/src/client/index.ts index b291bda3e7..237272bd1d 100644 --- a/packages/client/ui-brand-official/src/client/index.ts +++ b/packages/client/ui-brand-official/src/client/index.ts @@ -1,6 +1,7 @@ /** Official DeepSeek Harness occupants for the generic browser-brand slots. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx' diff --git a/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx index ee5275b9b7..8fa5ca8056 100644 --- a/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply, inject } from '../src/client/index.ts' import { OfficialBrandMark, OfficialBrandName } from '../src/client/Brand.tsx' diff --git a/packages/client/ui-brand-official/tsconfig.json b/packages/client/ui-brand-official/tsconfig.json index f98c0a8b2f..0ba9d872dd 100644 --- a/packages/client/ui-brand-official/tsconfig.json +++ b/packages/client/ui-brand-official/tsconfig.json @@ -12,7 +12,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../runtime" + "path": "../ui-renderer" }, { "path": "../ui-conversation" diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml new file mode 100644 index 0000000000..4abebdd3f3 --- /dev/null +++ b/packages/client/ui-chat/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md +README.md: 56bb20ab7b4d9b5c0c95142b311a07ad9b8a1fd3 +README.zh.md: 40ee1ee710e2f86e802ffaac4dc0bb10852f128f diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md new file mode 100644 index 0000000000..56bb20ab7b --- /dev/null +++ b/packages/client/ui-chat/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-chat + +English | [中文](README.zh.md) + +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, historical images, and scroll restoration. + +## Model Experience + +None, as this package renders logged conversation state in the browser and registers nothing model-facing. + +#### KV Cache effect + +None; Chat presentation does not assemble or mutate provider requests. + +## Known Limitations and Deferred Work + +- **The view reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md new file mode 100644 index 0000000000..40ee1ee710 --- /dev/null +++ b/packages/client/ui-chat/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-chat + +[English](README.md) | 中文 + +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化、历史图片与滚动位置恢复。 + +## 模型体验 + +无,因为本包在浏览器中渲染已记录的对话状态,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;Chat 呈现不会组装或修改提供方请求。 + +## 已知限制与暂缓事项 + +- **视图只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json new file mode 100644 index 0000000000..4b5275de98 --- /dev/null +++ b/packages/client/ui-chat/package.json @@ -0,0 +1,115 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-chat", + "description": "Chat Conversation target, node definitions, renderers, and details surface", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-chat" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-workspace-controller/client", + "@deepseek-ai/dsh-client-ui-conversation/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts new file mode 100644 index 0000000000..8b3dcd3e01 --- /dev/null +++ b/packages/client/ui-chat/src/client/apply.ts @@ -0,0 +1,141 @@ +/** Register the Chat Conversation target, renderers, stats, and details surface. */ +import type { Context } from '@deepseek-ai/cordis' +import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +// Type-only service and declaration merges used by the apply world. +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { + ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected, + TurnTailOwnerProps, +} from './contract/slots.ts' +import type { ChatSnapshot } from './contract/snapshot.ts' +import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts' +import { ApprovalCommand } from './chat/ApprovalCommand.tsx' +import { ChatView } from './chat/ChatView.tsx' +import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' +import { StatsLine } from './chat/StatsLine.tsx' +import { registerConversationNodes } from './conversation-nodes/register.ts' +import { DetailsPanel } from './details/DetailsPanel.tsx' +import { HistoricalImageCache } from './historical-images.ts' +import { en, NS, zh } from './locale.ts' +import { createChatStore } from './stores.ts' + +const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { + hooks: { + turnData: ({ useChat }, nodeKey) => function useTurnData(key) { + return useChat((snapshot) => { + const location = snapshot.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(key) + : undefined + }) + }, + }, +} + +/** Services required by the Chat target and its presentation registrations. */ +export const inject = [ + 'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', +] + +/** + * Mount all Chat-owned contributions. + * @param ctx - Client root context. + */ +export function apply(ctx: Context): void { + const chatSources = new WeakMap>() + const chatSource = (binding: SessionBinding): ObservableSnapshot => { + let source = chatSources.get(binding) + if (source === undefined) { + const target = ctx.uiConversation.binding(binding).target('chat') + source = { + getSnapshot: () => target.getSnapshot() ?? EMPTY_CHAT_SNAPSHOT, + subscribe: listener => target.subscribe(listener), + } + chatSources.set(binding, source) + } + return source + } + registerConversationNodes(ctx) + registerChatNodeRenderers(ctx) + ctx.uiSession.provide({ + hooks: ['chat'], + resolve: binding => ({ hooks: { chat: chatSource(binding) } }), + }) + + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-chat: dictionaries') + const t = ctx.locale.bind(NS) + const chatStore = createChatStore() + const chatScrollPositions = new Map() + const images = new HistoricalImageCache(ctx) + + ctx.slots.inject('conversation.view', () => { + const disposeView = ctx.slots.register({ + name: 'conversation.view', + id: 'chat', + order: 0, + label: () => t('view.chat'), + locale: NS, + children: { + 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, + 'conversation.message.images': { kind: 'single', scope: 'session' }, + }, + store: chatStore, + inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { + const session = ctx.sessions.binding(sessionId)?.session + if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) + return { + openDetails: (target) => { + actions.select(target) + ctx.layout.openDetails() + }, + fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner), + openFile: (path) => { + const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd + return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path)) + }, + loadOlder: () => { void session.loadOlder() }, + loadImage: attachment => images.resolve(sessionId, attachment), + chatScroll: { + save: (position) => { + if (position === null) chatScrollPositions.delete(sessionId) + else chatScrollPositions.set(sessionId, position) + }, + read: () => chatScrollPositions.get(sessionId) ?? null, + }, + forkAt: (seq) => { + ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true }) + .then((childId) => { ctx.sessions.open(childId) }) + .catch(() => { + // Fork or child-title failure leaves the source view unchanged. + }) + }, + } + }, + }, ChatView) + return disposeView + }) + + ctx.slots.inject('conversation.composer.dock', () => + ctx.slots.register({ + name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS, + }, StatsLine)) + + ctx.slots.inject('conversation.approval.detail', () => + ctx.slots.register({ name: 'conversation.approval.detail' }, ApprovalCommand)) + + ctx.slots.inject('details', () => ctx.slots.register({ + name: 'details', + locale: NS, + children: { 'conversation.details.tool': { kind: 'single', scope: 'session' } }, + store: chatStore, + inject: (): DetailsInjected => ({ closeDetails: () => { ctx.layout.closeDetails() } }), + }, DetailsPanel)) +} diff --git a/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx b/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx new file mode 100644 index 0000000000..32c3d3e415 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx @@ -0,0 +1,40 @@ +/** Chat-owned approval detail resolving a correlated Tool call's command. */ +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-approval/client' +import type { ChatNode } from '../contract/chat-nodes.ts' + +interface ApprovalToolCall { + readonly callId: string + readonly argsRaw: string +} + +/** + * Extract a shell command from a correlated Tool call when its arguments carry one. + * @param call - Tool call arguments, when a correlated call exists. + * @returns command text, or undefined for absent, malformed, or unrelated arguments. + */ +export function commandOf(call: ApprovalToolCall | undefined): string | undefined { + if (call === undefined) return undefined + try { + const args = JSON.parse(call.argsRaw) as Record + return typeof args.command === 'string' ? args.command : undefined + } catch { + return undefined + } +} + +/** + * Render the command of the Chat Tool node correlated with an approval. + * @param props - Approval identity and Session-standard Chat selector hook. + * @returns command text when the correlated call carries one. + */ +export function ApprovalCommand({ callId, useChat }: PropsRuntime<'conversation.approval.detail'>) { + const command = useChat((snapshot) => { + for (const node of snapshot.nodes.values()) { + const root = node.kind === 'tool-call' ? (node as ChatNode<'tool-call'>).data.root : undefined + if (root !== undefined && root.callId === callId && !('kind' in root)) return commandOf(root) + } + return undefined + }) + return command ?? null +} diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css rename to packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx similarity index 98% rename from packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx rename to packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index d47c8e12d0..208874d655 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -1,9 +1,9 @@ import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' -import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import type { AssistantBlock } from '../contract/snapshot.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' diff --git a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx similarity index 100% rename from packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx rename to packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx similarity index 91% rename from packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx rename to packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index bc9c96fd41..22a6d0dc35 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -6,7 +6,7 @@ import css from './ChatView.module.css' interface ChatNodeSeatProps extends ChatNodeOwnerProps { readonly nodeKey: string - readonly useSession: ChatViewSlotProps['useSession'] + readonly useChat: ChatViewSlotProps['useChat'] readonly renderSlot: ChatViewSlotProps['renderSlot'] readonly t: ChatViewSlotProps['t'] } @@ -18,9 +18,9 @@ type RoutedChatNodeOwner = { /** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, - renderMessageImages, fileMentions, useSession, renderSlot, t, + renderMessageImages, fileMentions, useChat, renderSlot, t, }: ChatNodeSeatProps) { - const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) + const node = useChat(snapshot => snapshot.nodes.get(nodeKey)) const routedNode = node as ChatNode | undefined const owner = useMemo(() => node === undefined ? null diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/ChatView.module.css rename to packages/client/ui-chat/src/client/chat/ChatView.module.css diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx similarity index 97% rename from packages/client/ui-conversation/src/client/chat/ChatView.tsx rename to packages/client/ui-chat/src/client/chat/ChatView.tsx index 8964a32482..e835273107 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -2,7 +2,7 @@ // otherwise this view owns it. Each row subscribes to one stable node key. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' @@ -144,12 +144,12 @@ function TurnStatus({ startTime, t }: { * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, inspectCall, chatScroll, forkAt, + useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt, fileMentions, t, }: ChatViewSlotProps) { - const order = useSession(s => s.chat.order) - const nodeStore = useSession(s => s.chat.nodes) - const timeline = useSession(s => s.chat.timeline) + const order = useChat(s => s.order) + const nodeStore = useChat(s => s.nodes) + const timeline = useChat(s => s.timeline) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -159,6 +159,9 @@ export function ChatView({ const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) + const inspectCall = useCallback((callId: string) => { + openView('trajectory', callId) + }, [openView]) const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) const [fileOpenBusy, setFileOpenBusy] = useState(false) // Close/retry must ignore a settlement that started before the latest @@ -421,7 +424,7 @@ export function ChatView({ () let steps = 0 let llmMs = 0 @@ -170,46 +174,16 @@ export function billedInputTokens(usage: TokenUsageProjection): number { return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens } -interface ContextOccupancy { - percent: number - usedTokens: number - contextWindow: number -} - -/** - * Approximate context occupancy, using the TUI's integer rounding and upper - * clamp. The numerator is `projectedTokens` — the provider sample carried - * forward over the surface's movement since — so compaction shows immediately - * instead of waiting for the next request to report usage; it falls back to the - * bare sample only for a log whose projection predates that field. Numerator - * and capacity remain independent last-wins projection fields, so this is a - * reference figure rather than an exact measurement of one request (see the - * token-meter README). - * @param pressure - the session's context-pressure projection value. - * @returns occupancy with its numerator and denominator, or null until both values are known. - */ -export function contextOccupancy( - pressure: ContextPressureProjection | undefined, -): ContextOccupancy | null { - const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens - if (usedTokens === undefined || pressure?.contextWindow === undefined) return null - return { - percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)), - usedTokens, - contextWindow: pressure.contextWindow, - } -} - /** Props: the conversation-snapshot selector plus the projection read seat. */ export interface StatsLineProps { - useSession: SnapshotSelectorHook + useChat: SnapshotSelectorHook useProjection: UseProjection /** The owning dock's locale seat. */ - t: ComposerBarProps['t'] + t: ChatViewSlotProps['t'] } -export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { - const settledNodes = useSession(s => s.chat.legacy.nodes) +export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }: StatsLineProps) { + const settledNodes = useChat(s => s.legacy.nodes) const usage = useProjection('tokenUsage') // Every figure rides the durable sessionStats projection, so paging and // compaction cannot change any of them; an assembly without the unit falls diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css rename to packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx similarity index 92% rename from packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx rename to packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index d4d27570f9..3fd7619a49 100644 --- a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -10,11 +10,11 @@ type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession, + node, openFile, forkAt, renderSlot, renderSlotChain, t, useChat, }: TurnTailNodeViewProps) { const data = node.data - const hasLaterChatNode = useSession(snapshot => - snapshot.chat.locations.getTurn(data.turn).at(-1) !== node.key) + const hasLaterChatNode = useChat(snapshot => + snapshot.locations.getTurn(data.turn).at(-1) !== node.key) const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined diff --git a/packages/client/ui-conversation/src/client/chat/accessibility.module.css b/packages/client/ui-chat/src/client/chat/accessibility.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/accessibility.module.css rename to packages/client/ui-chat/src/client/chat/accessibility.module.css diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-chat/src/client/chat/message-chrome.ts similarity index 100% rename from packages/client/ui-conversation/src/client/chat/message-chrome.ts rename to packages/client/ui-chat/src/client/chat/message-chrome.ts diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts similarity index 98% rename from packages/client/ui-conversation/src/client/chat/register-node-renderers.ts rename to packages/client/ui-chat/src/client/chat/register-node-renderers.ts index ed311e4b74..826e1344f2 100644 --- a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -1,5 +1,5 @@ import type { Context } from '@deepseek-ai/cordis' -import { NS } from '../locales.ts' +import { NS } from '../locale.ts' import { AssistantNodeView } from './AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' import { diff --git a/packages/client/ui-conversation/src/client/chat/turn-assistant.ts b/packages/client/ui-chat/src/client/chat/turn-assistant.ts similarity index 80% rename from packages/client/ui-conversation/src/client/chat/turn-assistant.ts rename to packages/client/ui-chat/src/client/chat/turn-assistant.ts index 2abfff56b3..90e075d79a 100644 --- a/packages/client/ui-conversation/src/client/chat/turn-assistant.ts +++ b/packages/client/ui-chat/src/client/chat/turn-assistant.ts @@ -1,4 +1,4 @@ -import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantBlock } from '../contract/snapshot.ts' /** * Collect visible prose from one Assistant lifecycle. diff --git a/packages/client/ui-conversation/src/client/chat/use-calendar-day.ts b/packages/client/ui-chat/src/client/chat/use-calendar-day.ts similarity index 100% rename from packages/client/ui-conversation/src/client/chat/use-calendar-day.ts rename to packages/client/ui-chat/src/client/chat/use-calendar-day.ts diff --git a/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts b/packages/client/ui-chat/src/client/chat/use-throttled-visual-update.ts similarity index 100% rename from packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts rename to packages/client/ui-chat/src/client/chat/use-throttled-visual-update.ts diff --git a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts similarity index 82% rename from packages/client/ui-conversation/src/client/contract/chat-nodes.ts rename to packages/client/ui-chat/src/client/contract/chat-nodes.ts index c057d15d1b..b4f8605c4f 100644 --- a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -1,7 +1,18 @@ import type { - AssistantBlock, AssistantMessageNode, ChatConversationViewNode, CommandNode, - CompactionSummaryNode, ModelRetryNode, RunningToolCall, ToolCallBlock, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationViewNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + AssistantBlock, AssistantMessageNode, CommandNode, CompactionSummaryNode, + ModelRetryNode, RunningToolCall, ToolCallBlock, +} from './snapshot.ts' + +/** Final Chat render unit produced by a Chat business Definition. */ +export interface ChatConversationViewNode extends ConversationViewNode { + readonly target: 'chat' + readonly anchorSeq: number + readonly location: ConversationLocation + readonly visibility: 'visible' | 'hidden' +} /** Merge-extensible payload registry keyed by final Chat renderer kind. */ export interface ChatNodeDataMap {} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts new file mode 100644 index 0000000000..5009781fd1 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -0,0 +1,205 @@ +/** Chat-owned Slot declarations and composed component props. */ +import type { ReactNode } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { + ConversationTurnDataMap, TurnLocation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, + SnapshotSelectorHook, +} from '@deepseek-ai/dsh-client-ui-slots' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { createChatStore } from '../stores.ts' +import type { CallId, SelectionTarget } from './store.ts' +import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' +import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts' + +/** Selector hook over the current Conversation binding's Chat target. */ +export type UseChat = SnapshotSelectorHook + +/** Historical image group handed to the optional attachment presentation plugin. */ +export interface MessageImagesOwnerProps { + images: readonly { readonly attachment: ImageAttachmentRef }[] + loadImage: (attachment: ImageAttachmentRef) => Promise + align: 'start' | 'end' +} + +/** Slot-backed renderer used by Chat nodes without importing an attachment implementation. */ +export type RenderMessageImages = (owner: Omit) => ReactNode + +/** Owner currency of the completed-Turn extension chain. */ +export interface TurnTailOwnerProps { + turn: TurnLocation + seq: number + openFile: (path: string) => void +} + +/** Owner currency of finalized-assistant actions. */ +export interface AssistantActionOwnerProps { + messageId: MessageId +} + +/** Optional prose file-mention provider consumed by Chat. */ +export interface ChatFileMentions { + /** + * Resolve prose links for one closing Turn. + * @param owner - closing-Turn identity and file opener. + * @returns link resolver when available. + */ + forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Optional prose file-mention provider. */ + chatFileMentions: ChatFileMentions + } +} + +/** Hook constrained to business data published on the current Chat Node's Turn. */ +export type UseChatNodeTurnData = >( + key: Key, +) => Readonly | undefined + +/** Slot-level Hook factory for keyed Chat renderers. */ +export interface ChatNodeTurnDataInjected { + hooks: { turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> } +} + +/** Stable owner currency delivered to a keyed Chat renderer. */ +export interface ChatNodeOwnerProps { + selectedCallId?: CallId | undefined + cwd?: string | undefined + openFile: (path: string) => void + inspectCall: (callId: CallId) => void + forkAt: (seq: number) => void + renderMessageImages: RenderMessageImages + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined +} + +/** Full props of one keyed Chat renderer. */ +export type ChatNodeViewProps = + PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'chat'> + +/** Tool block rendered in the details panel. */ +export interface DetailsToolOwnerProps { + block: ToolCallBlock + cwd?: string | undefined +} + +/** Command-row owner share. */ +export interface CommandRowOwnerProps { + node: CommandNode + compaction?: CompactionSummaryNode +} + +/** Full props of a registered command row. */ +export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'> + +/** Shared Chat store handle. */ +export type ChatStore = ReturnType + +/** In-memory reader position resilient to transcript reflow. */ +export interface ChatScrollPosition { + readonly anchorKey: string + readonly anchorTop: number + readonly scrollTop: number +} + +/** Business callbacks injected into the Chat view. */ +export interface ChatViewInjected { + openDetails: (target: SelectionTarget) => void + openFile: (path: string) => Promise + loadOlder: () => void + loadImage: (attachment: ImageAttachmentRef) => Promise + chatScroll: { + save: (position: ChatScrollPosition | null) => void + read: () => ChatScrollPosition | null + } + forkAt: (seq: number) => void + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined +} + +/** Full Chat view props. */ +export type ChatViewSlotProps = + PropsRuntime<'conversation.view'> + & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'> + & PropsStore + & InjectFace + & PropsLocale<'chat'> + +/** Full props of the durable-message image renderer. */ +export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'> + +/** Details-panel callbacks. */ +export interface DetailsInjected { + closeDetails: () => void +} + +/** Full details-panel props. */ +export type DetailsSlotProps = + PropsRuntime<'details'> + & PropsRenderSlots<'conversation.details.tool'> + & PropsStore + & InjectFace + & PropsLocale<'chat'> + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SessionStandardProps { + /** Selector hook over the current Conversation binding's Chat target. */ + useChat: UseChat + } + + interface LocaleNamespaceMap { + /** Chat target, transcript node, statistics, and details copy. */ + chat: import('../locale.ts').ChatKey + } + + interface SlotMap { + /** + * Final Chat node renderer, keyed by `ChatNodeKind`. The component receives + * the typed node, shared Chat actions, and Turn-data hook. Reusing a key + * replaces that node renderer; a kind with no occupant renders no row. + */ + 'conversation.chat.node': { + kind: 'keyed' + scope: 'session' + owner: ChatNodeOwnerProps + keyProps: { [Kind in ChatNodeKind]: { node: ChatNode } } + hookContext: string + inject: ChatNodeTurnDataInjected + } + /** + * Renderer for one consecutive group of durable message images. The owner + * supplies image references, an authorized loader, and alignment. A + * registration replaces the shipped gallery; without one, images are omitted. + */ + 'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } + /** + * Command row keyed by the command name. The component receives the folded + * command lifecycle and linked compaction when present. Reusing a key + * replaces that command renderer; an unoccupied key uses the generic card. + */ + 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } + /** + * Selector-routed extension before a completed Turn's action row. The + * component receives the Turn, closing sequence, and file opener. The first + * selector that accepts the owner renders; an all-declined chain is empty. + */ + 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } + /** + * Ordered actions for one finalized assistant message. Each entry receives + * the durable message id; a fresh `id` adds an action and reusing one replaces + * that entry. With no entries, the standard action row remains unchanged. + */ + 'conversation.chat.assistant-actions': { kind: 'list'; scope: 'session'; owner: AssistantActionOwnerProps } + /** + * Whole details-panel body for the selected Tool call. The component receives + * the running or settled block and optional workspace root. A registration + * replaces the shipped Tool details renderer; absence uses the raw fallback. + */ + 'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps } + } +} diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts new file mode 100644 index 0000000000..9a938cfa09 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -0,0 +1,79 @@ +import type { + ConversationNode, ConversationTimelineSnapshot, PartialAssistant, RunningToolCall, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from './chat-nodes.ts' + +export type { + AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, + AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, + ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, + ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, + UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +export { + emptyAssistantBlock, toAssistantBlock, toAssistantBlocks, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +/** Stable live per-key reader for Chat nodes. */ +export interface ChatNodeStore { + /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ + get(key: string): ChatConversationViewNode | undefined + /** @returns all currently materialized Nodes without imposing render order. */ + values(): readonly ChatConversationViewNode[] +} + +/** Stable live Location index for Chat nodes. */ +export interface ChatLocationNodeIndex { + /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ + getTurn(turn: number): readonly string[] + /** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */ + getStep(turn: number, step: number): readonly string[] +} + +/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */ +export interface LegacyConversationSlice { + readonly nodes: readonly ConversationNode[] + readonly turnTimings: ReadonlyMap + readonly turnEnds: ReadonlyMap + readonly partial: PartialAssistant | null + readonly runningCalls: readonly RunningToolCall[] +} + +/** Incremental Chat publication with immutable order and stable live keyed readers. */ +export interface ChatSnapshot { + readonly order: readonly string[] + readonly nodes: ChatNodeStore + readonly locations: ChatLocationNodeIndex + readonly timeline: ConversationTimelineSnapshot + readonly legacy: LegacyConversationSlice +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationViewSnapshotMap { + chat: ChatSnapshot + } +} + +const EMPTY_LIST: readonly never[] = [] +const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } + +/** Empty Chat target used before a view builder is registered. */ +export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { + order: EMPTY_LIST, + nodes: { + get: () => undefined, + values: () => EMPTY_LIST, + }, + locations: { + getTurn: () => EMPTY_LIST, + getStep: () => EMPTY_LIST, + }, + timeline: EMPTY_TIMELINE, + legacy: { + nodes: EMPTY_LIST, + turnTimings: new Map(), + turnEnds: new Map(), + partial: null, + runningCalls: EMPTY_LIST, + }, +} diff --git a/packages/client/ui-chat/src/client/contract/store.ts b/packages/client/ui-chat/src/client/contract/store.ts new file mode 100644 index 0000000000..41a3e58052 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/store.ts @@ -0,0 +1,17 @@ +/** Chat-owned selection state shared by the transcript and details panel. */ + +/** Tool call identity as carried by Chat nodes. */ +export type CallId = string + +/** Selection target for the Chat details linkage channel. */ +export interface SelectionTarget { + turnSeq: number + stepSeq?: number + callId?: CallId + toolName?: string +} + +/** Per-Session state shared only by the Chat view and details surface. */ +export interface ChatStoreState { + selection: SelectionTarget | null +} diff --git a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts b/packages/client/ui-chat/src/client/contract/turn-metrics.ts similarity index 97% rename from packages/client/ui-conversation/src/client/chat/turn-metrics.ts rename to packages/client/ui-chat/src/client/contract/turn-metrics.ts index b93bbfc8eb..2f8f0eabbc 100644 --- a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts +++ b/packages/client/ui-chat/src/client/contract/turn-metrics.ts @@ -1,6 +1,6 @@ // Latency/throughput folds shared by the settled turn footer and StatsLine. -import type { AssistantMessageNode, ConversationNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantMessageNode, ConversationNode } from './snapshot.ts' /** Latency and decode-throughput readings for one turn's footer. */ export interface TurnMetrics { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts similarity index 94% rename from packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts rename to packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index df8f87ee53..7eb11d7ab9 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -1,23 +1,24 @@ import type { Context } from '@deepseek-ai/cordis' import type { - AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, - ConversationNodeContext, ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import { - emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' import type {} from '@deepseek-ai/dsh-llm-retry/types' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { AssistantChatData } from '../contract/chat-nodes.ts' +import type { AssistantBlock, AssistantMessageNode } from '../contract/snapshot.ts' +import { toAssistantBlock, toAssistantBlocks } from '../contract/snapshot.ts' +import { emptyAssistantBlock } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Streaming, settled, or interrupted Assistant step. */ 'assistant-step': AssistantChatData } } -declare module '@deepseek-ai/dsh-client-runtime/client' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationStepDataMap { /** Streaming, settled, or interrupted Assistant material for this Step. */ 'assistant-step': AssistantChatData @@ -314,5 +315,5 @@ export const assistantDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerAssistantConversationNode(ctx: Context): void { - ctx.conversationEvents.register(assistantDefinition) + ctx.uiConversation.events.register(assistantDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts similarity index 96% rename from packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts rename to packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 651fc780a3..ce55ef45e9 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,13 +1,15 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, - ConversationLocation, ConversationNode, ConversationTimelineSnapshot, - ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice, - PartialAssistant, RunningToolCall, -} from '@deepseek-ai/dsh-client-runtime/client' -import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatNode } from '../contract/chat-nodes.ts' + ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder, + ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' +import type { + ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationNode, + LegacyConversationSlice, PartialAssistant, RunningToolCall, +} from '../contract/snapshot.ts' +import { sessionRecallLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' const EMPTY_KEYS: readonly string[] = [] const EMPTY_TURNS: readonly number[] = [] @@ -542,10 +544,11 @@ function locationIdentity(location: ConversationLocation): string { return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}` } -/** Chat target factory contributed to the Runtime view registry. */ +/** Chat target factory contributed to the Conversation view registry. */ export const chatViewDefinition: ConversationViewDefinition = { target: 'chat', create: () => new ChatSnapshotBuilder(), + isActive: snapshot => snapshot.order.some(key => snapshot.nodes.get(key)?.kind !== 'command'), } /** @@ -553,5 +556,5 @@ export const chatViewDefinition: ConversationViewDefinition = { * @param ctx - owning UI Conversation context. */ export function registerCommandConversationNode(ctx: Context): void { - ctx.conversationEvents.register(commandDefinition) + ctx.uiConversation.events.register(commandDefinition) } /** Shared structural checkpoint recognizer for automatic compaction. */ diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts b/packages/client/ui-chat/src/client/conversation-nodes/common.ts similarity index 97% rename from packages/client/ui-conversation/src/client/conversation-nodes/common.ts rename to packages/client/ui-chat/src/client/conversation-nodes/common.ts index 1e2693bfff..fd63c47cdd 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/common.ts @@ -1,6 +1,6 @@ import type { ConversationLocation, ConversationNodeContext, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNode, ChatNodeDataMap, ChatNodeKind, } from '../contract/chat-nodes.ts' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts similarity index 88% rename from packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts rename to packages/client/ui-chat/src/client/conversation-nodes/compaction.ts index 41f58f35eb..5874c7bd61 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts @@ -1,12 +1,13 @@ import type { Context } from '@deepseek-ai/cordis' import type { - CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' +import type { CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Automatic compaction checkpoint marker. */ compaction: CompactionSummaryNode @@ -61,5 +62,5 @@ export const compactionDefinition: ConversationNodeDefinition = * @param ctx - owning UI Conversation context. */ export function registerCompactionConversationNode(ctx: Context): void { - ctx.conversationEvents.register(compactionDefinition) + ctx.uiConversation.events.register(compactionDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts similarity index 75% rename from packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts rename to packages/client/ui-chat/src/client/conversation-nodes/fallback.ts index 79bc97e636..356b46ed62 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts @@ -1,11 +1,10 @@ import type { Context } from '@deepseek-ai/cordis' -import type { - ConversationNodeDefinition, UnknownSurfaceNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type { UnknownSurfaceNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Generic presentation of an unclaimed append-surface event. */ unknown: UnknownSurfaceNode @@ -37,5 +36,5 @@ export const unknownFallbackDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerMessageConversationNode(ctx: Context): void { - ctx.conversationEvents.register(messageDefinition) + ctx.uiConversation.events.register(messageDefinition) } diff --git a/packages/client/runtime/src/client/sessions/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts similarity index 84% rename from packages/client/runtime/src/client/sessions/partial.ts rename to packages/client/ui-chat/src/client/conversation-nodes/partial.ts index 478b5b2791..cc94609bb3 100644 --- a/packages/client/runtime/src/client/sessions/partial.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts @@ -1,6 +1,6 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' -import type { AssistantBlock, PartialAssistant } from './conversation.ts' -import { toAssistantBlock } from './conversation.ts' +import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts' +import { emptyAssistantBlock, toAssistantBlock } from '../contract/snapshot.ts' /** * Whether a stream chunk changes the partial assistant projection shown by the UI. @@ -97,17 +97,3 @@ export class PartialAccumulator { return this.snapshot } } - -/** - * Create the empty client projection for one streamed Assistant block kind. - * @param blockType - wire block kind. - * @returns empty projected block ready to receive deltas. - */ -export function emptyAssistantBlock(blockType: string): AssistantBlock { - switch (blockType) { - case 'text': return { kind: 'text', text: '' } - case 'reasoning': return { kind: 'reasoning', text: '' } - case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' } - default: return { kind: 'other', block: null } - } -} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts b/packages/client/ui-chat/src/client/conversation-nodes/register.ts similarity index 100% rename from packages/client/ui-conversation/src/client/conversation-nodes/register.ts rename to packages/client/ui-chat/src/client/conversation-nodes/register.ts diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts similarity index 92% rename from packages/client/ui-conversation/src/client/conversation-nodes/retry.ts rename to packages/client/ui-chat/src/client/conversation-nodes/retry.ts index 4a0f9f9fed..5209e1dbc2 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -1,12 +1,13 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationNodeDefinition, ModelRetryNode, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { RetryChatData } from '../contract/chat-nodes.ts' +import type { ModelRetryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Producer-correlated model retry chain. */ 'model-retry': RetryChatData @@ -93,5 +94,5 @@ export const retryDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerRetryConversationNode(ctx: Context): void { - ctx.conversationEvents.register(retryDefinition) + ctx.uiConversation.events.register(retryDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts similarity index 97% rename from packages/client/ui-conversation/src/client/conversation-nodes/tool.ts rename to packages/client/ui-chat/src/client/conversation-nodes/tool.ts index 0d6fb57cf3..20ce57831f 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,14 +1,14 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, - RunningToolCall, ToolCallBlock, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' import type { ToolChatData } from '../contract/chat-nodes.ts' +import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Root Tool lifecycle with recursively nested subcalls. */ 'tool-call': ToolChatData @@ -273,5 +273,5 @@ export const toolDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerToolConversationNode(ctx: Context): void { - ctx.conversationEvents.register(toolDefinition) + ctx.uiConversation.events.register(toolDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts similarity index 90% rename from packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts rename to packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index f2d6f6fc26..cee7c5c9dc 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -1,11 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client' + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { TurnErrorNode } from '../contract/snapshot.ts' +import { displayFailureMessage } from '@deepseek-ai/dsh-client-ui-conversation/client' import { chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Terminal turn failure recorded on the turn's end reason. */ 'turn-error': TurnErrorNode @@ -92,5 +93,5 @@ export const turnErrorDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerTurnErrorConversationNode(ctx: Context): void { - ctx.conversationEvents.register(turnErrorDefinition) + ctx.uiConversation.events.register(turnErrorDefinition) } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts similarity index 92% rename from packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts rename to packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index baf83a1b3c..31508ab77c 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -1,10 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { TurnMaxTokensNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { /** Turn ended by the per-request output-token cap. */ 'turn-max-tokens': TurnMaxTokensNode @@ -78,5 +79,5 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition = { * @param ctx - owning UI Conversation context. */ export function registerTurnTailConversationNode(ctx: Context): void { - ctx.conversationEvents.register(turnTailDefinition) + ctx.uiConversation.events.register(turnTailDefinition) } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-chat/src/client/details/DetailsPanel.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css rename to packages/client/ui-chat/src/client/details/DetailsPanel.module.css diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx similarity index 89% rename from packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx rename to packages/client/ui-chat/src/client/details/DetailsPanel.tsx index 33a6194a53..d3109076f8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx @@ -1,9 +1,9 @@ import { Fragment } from 'react' import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' -import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import { shallowEqual } from '@deepseek-ai/dsh-client-store' import type { DetailsSlotProps } from '../contract/slots.ts' -import { findToolCall } from '../chat/tool-node-reader.ts' +import type { ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' +import { findToolCall } from './tool-node-reader.ts' import css from './DetailsPanel.module.css' export type DetailsPanelProps = DetailsSlotProps @@ -23,7 +23,7 @@ function runningMaterial(call: RunningToolCall): CallMaterial { return { name: call.name, argsRaw: call.argsRaw, block: call } } -function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { +function materialFor(s: ChatSnapshot, callId: string): CallMaterial | null { const found = findToolCall(s, callId) if (found === undefined) return null return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) @@ -45,7 +45,7 @@ function rawResultText(block: ToolCallBlock): string { return parts.join('\n') } -export function DetailsPanel({ useSession, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) { +export function DetailsPanel({ useChat, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) { const selection = useStore(s => s.selection) // Session workspace root: an omitted or relative terminal cwd resolves // against it, which the pure presenter cannot see. @@ -53,7 +53,7 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, ren const callId = selection?.callId // materialFor builds a fresh wrapper; shallowEqual short-circuits on its // stable members (result node reference rides the snapshot's structural sharing). - const material = useSession( + const material = useChat( s => (callId === undefined ? null : materialFor(s, callId)), (a, b) => shallowEqual(a, b)) diff --git a/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts b/packages/client/ui-chat/src/client/details/tool-node-reader.ts similarity index 70% rename from packages/client/ui-conversation/src/client/chat/tool-node-reader.ts rename to packages/client/ui-chat/src/client/details/tool-node-reader.ts index dca9992b8d..690cea34bc 100644 --- a/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts +++ b/packages/client/ui-chat/src/client/details/tool-node-reader.ts @@ -1,10 +1,8 @@ -import type { - ConversationSnapshot, ToolCallBlock, -} from '@deepseek-ai/dsh-client-runtime/client' -import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' +import { conversationContextKey } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts' -function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { +function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined } @@ -15,10 +13,10 @@ function toolNode(node: ReturnType * @returns root lifecycle when it is materialized in the current window. */ export function rootToolCall( - snapshot: ConversationSnapshot, + snapshot: ChatSnapshot, rootCallId: string, ): ToolCallBlock | undefined { - return toolNode(snapshot.chat.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root + return toolNode(snapshot.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root } /** @@ -27,7 +25,7 @@ export function rootToolCall( * @param callId - root or nested call identity. * @returns current Tool lifecycle when materialized in the loaded window. */ -export function findToolCall(snapshot: ConversationSnapshot, callId: string): ToolCallBlock | undefined { +export function findToolCall(snapshot: ChatSnapshot, callId: string): ToolCallBlock | undefined { const visit = (block: ToolCallBlock): ToolCallBlock | undefined => { if (block.callId === callId) return block for (const child of block.subCalls) { @@ -36,7 +34,7 @@ export function findToolCall(snapshot: ConversationSnapshot, callId: string): To } return undefined } - for (const node of snapshot.chat.nodes.values()) { + for (const node of snapshot.nodes.values()) { const root = toolNode(node)?.data.root if (root === undefined) continue const found = visit(root) diff --git a/packages/client/ui-chat/src/client/historical-images.ts b/packages/client/ui-chat/src/client/historical-images.ts new file mode 100644 index 0000000000..6203d4343e --- /dev/null +++ b/packages/client/ui-chat/src/client/historical-images.ts @@ -0,0 +1,107 @@ +/** Session-scoped historical image URL cache owned by the Chat plugin. */ +import type { Context } from '@deepseek-ai/cordis' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import { bytesToBase64 } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +interface ImageUrlEntry { + readonly sessionId: SessionId + readonly generation: number + readonly pending: Promise +} + +/** Resolve durable Chat images and release their browser URLs with Session scope. */ +export class HistoricalImageCache { + private readonly sessions: ISessions + private readonly entries = new Map() + private readonly generations = new Map() + private readonly scopeDisposers = new Map void>() + private readonly urls = new Set() + private disposed = false + + /** + * @param ctx - Owning ui-chat fiber. + */ + constructor(ctx: Context) { + this.sessions = ctx.sessions + ctx.effect(() => () => { this.dispose() }, 'ui-chat historical image cache') + } + + /** + * Resolve and cache one session-authorized image URL. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference. + * @returns browser URL valid until the Session binding is released. + */ + resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + if (this.disposed) return Promise.reject(new Error('ui-chat image cache is disposed')) + const key = `${sessionId}:${attachment.attachmentId}` + const cached = this.entries.get(key) + if (cached !== undefined) return cached.pending + const binding = this.sessions.binding(sessionId) + if (binding === undefined) { + return Promise.reject(new Error(`ui-chat: unknown session "${sessionId}"`)) + } + this.bindScope(sessionId, binding.ctx) + const generation = this.generations.get(sessionId) ?? 0 + const pending = binding.session.readAttachment(attachment.attachmentId) + .then((result) => { + if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) + if (this.disposed) throw new Error('ui-chat image cache was disposed before loading completed') + if ((this.generations.get(sessionId) ?? 0) !== generation) { + throw new Error('ui-chat image scope was released before loading completed') + } + if (typeof URL.createObjectURL !== 'function') { + return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` + } + const bytes = Uint8Array.from(result.value.data) + const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) + this.urls.add(url) + return url + }) + .catch((error: unknown) => { + if (this.entries.get(key)?.generation === generation) this.entries.delete(key) + throw error + }) + this.entries.set(key, { sessionId, generation, pending }) + return pending + } + + private bindScope(sessionId: SessionId, scope: Context): void { + if (this.scopeDisposers.has(sessionId)) return + const dispose = scope.effect(() => () => { + this.scopeDisposers.delete(sessionId) + this.release(sessionId) + }, 'ui-chat historical image scope') + this.scopeDisposers.set(sessionId, () => { void dispose() }) + } + + private release(sessionId: SessionId): void { + this.generations.set(sessionId, (this.generations.get(sessionId) ?? 0) + 1) + for (const [key, entry] of this.entries) { + if (entry.sessionId !== sessionId) continue + this.entries.delete(key) + void entry.pending.then((url) => { + if (!this.urls.delete(url)) return + revokeUrl(url) + }, () => { + // Failed and invalidated loads create no browser URL. + }) + } + } + + private dispose(): void { + if (this.disposed) return + this.disposed = true + for (const dispose of [...this.scopeDisposers.values()]) dispose() + this.scopeDisposers.clear() + for (const url of this.urls) revokeUrl(url) + this.urls.clear() + this.entries.clear() + } +} + +function revokeUrl(url: string): void { + if (url.startsWith('blob:')) URL.revokeObjectURL(url) +} diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts new file mode 100644 index 0000000000..9c82da7564 --- /dev/null +++ b/packages/client/ui-chat/src/client/index.ts @@ -0,0 +1,56 @@ +/** Browser Chat target plugin. */ +export { apply, inject } from './apply.ts' +export type {} from './conversation-nodes/assistant.ts' +export type {} from './conversation-nodes/command.ts' +export type {} from './conversation-nodes/compaction.ts' +export type {} from './conversation-nodes/fallback.ts' +export type {} from './conversation-nodes/message.ts' +export type {} from './conversation-nodes/retry.ts' +export type {} from './conversation-nodes/tool.ts' +export type {} from './conversation-nodes/turn-error.ts' +export type {} from './conversation-nodes/turn-max-tokens.ts' +export type {} from './conversation-nodes/turn-tail.ts' + +export type { + AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, + AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, CommandNode, + CompactionSummaryNode, ContextMessageNode, ConversationNode, LegacyConversationSlice, + ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, ToolCallBlock, + ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode, +} from './contract/snapshot.ts' +export type { + AssistantChatData, ChatConversationViewNode, ChatNode, ChatNodeKind, + FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, + TurnTailChatData, +} from './contract/chat-nodes.ts' +export type { CallId, ChatStoreState, SelectionTarget } from './contract/store.ts' +export type { + AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, + ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, + CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps, + DetailsToolOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, + TurnTailOwnerProps, UseChat, UseChatNodeTurnData, +} from './contract/slots.ts' +export type { ChatKey } from './locale.ts' +export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts' +export type { + ContextProvenanceView, ContextRole, KnownContextForm, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +export type { + ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts' +export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts' +export { + contextForm, contextProvenance, displayFailureMessage, emptyAssistantBlock, isTokenDelta, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +/** Public merge surface for Chat renderer payloads contributed by other plugins. */ +export interface ChatNodeDataMap {} + +type PublicChatNodeDataMap = ChatNodeDataMap + +declare module './contract/chat-nodes.ts' { + interface ChatNodeDataMap extends PublicChatNodeDataMap {} +} diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts new file mode 100644 index 0000000000..beb059bdf1 --- /dev/null +++ b/packages/client/ui-chat/src/client/locale.ts @@ -0,0 +1,159 @@ +/** Chat-owned locale namespace and dictionaries. */ + +/** Namespace for Chat target, node, statistics, and details copy. */ +export const NS = 'chat' + +/** Simplified Chinese dictionary and key-set source of truth. */ +export const zh = { + 'view.chat': '对话', + 'stats.counts': '{turns} 轮 · {steps} 步', + 'stats.llm': 'LLM {duration}', + 'stats.toolCall': '工具调用 {duration}', + 'stats.ttftAverage': '首 token 平均 {duration}', + 'stats.tokensPerSecond': '{throughput} tok/s', + 'stats.cacheHit': '缓存命中 {percent}%', + 'stats.tokens': '输入 {input} tok · 输出 {output} tok', + 'details.title': '详情', + 'details.close': '关闭详情', + 'details.empty': '点击消息流中的工具行查看详情', + 'details.notInWindow': '该调用不在当前窗口内', + 'details.input': '输入', + 'details.output': '输出', + 'details.running': '运行中…', + 'chat.loadingHistory': '载入历史…', + 'chat.loadError': '历史加载失败:{message}({code})', + 'chat.loadOlder': '加载更早', + 'chat.toBottom': '回到底部', + 'fileOpen.title': '无法打开文件', + 'fileOpen.unknown': '无法打开此文件', + 'fileOpen.folderTitle': '无法打开文件夹', + 'fileOpen.folderUnknown': '无法打开此文件夹', + 'message.extraBlock': '附加内容块', + 'message.contextInjection': '上下文注入', + 'message.contextRecall': '跨会话召回', + 'message.referenceSummary': '引用会话 · {labels}', + 'message.referenceSeparator': '、', + 'message.context.instructions.loaded': '已载入', + 'message.context.instructions.added': '已新增', + 'message.context.instructions.updated': '已更新', + 'message.context.instructions.removed': '已移除', + 'message.context.catalog.replaced': '替换目录', + 'message.context.catalog.more': '…还有 {count} 条', + 'message.context.snapshot.supersedes': '取代先前的快照', + 'message.context.relay.from': '来自会话 {session}', + 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', + 'message.context.recall.truncated': '已截断', + 'message.compaction': '上下文已压缩', + 'message.compaction.running': '正在压缩…', + 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', + 'message.compaction.expand': '点击查看压缩摘要', + 'message.compaction.unavailable': '压缩摘要不可用', + 'message.unknownSurface': '未知 surface 事件:{type}', + 'message.unknownBlock': '未知内容块', + 'message.stopped': '已停止', + 'message.branch': '在新对话中分支', + 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', + 'message.retry.active': '正在重试模型请求', + 'message.retry.cancelled': '模型请求重试已取消', + 'message.retry.started': '已重试模型请求', + 'message.retry.scheduled': '等待重试模型请求', + 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': '重试延迟:', + 'message.retry.failure': '失败原因:', + 'message.turnError': '本轮运行失败', + 'message.maxTokens': '已达到输出 token 上限', + 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', + 'message.ranFor': '用时 {duration}', + 'message.ttft': '首 token {seconds}秒', + 'message.tokensPerSecond': '{tps} tok/s', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'command.running': '执行中…', + 'command.failed': '命令失败', + 'command.done': '已完成', + 'command.title': '命令', + 'row.running': '运行中', + 'row.failed': '失败', + 'json.truncated': '… 已截断,共 {total} 字符', + 'clock.md': '{m}月{d}日', + 'clock.ymd': '{y}年{m}月{d}日', +} satisfies Record + +/** Chat dictionary key union. */ +export type ChatKey = keyof typeof zh + +/** English dictionary, checked against the Chinese key set. */ +export const en = { + 'view.chat': 'Chat', + 'stats.counts': '{turns} turns · {steps} steps', + 'stats.llm': 'LLM {duration}', + 'stats.toolCall': 'Tool call {duration}', + 'stats.ttftAverage': 'TTFT avg {duration}', + 'stats.tokensPerSecond': '{throughput} tok/s', + 'stats.cacheHit': 'Cache hit {percent}%', + 'stats.tokens': 'Input {input} tok · Output {output} tok', + 'details.title': 'Details', + 'details.close': 'Close details', + 'details.empty': 'Click a tool row in the message flow to view its details', + 'details.notInWindow': 'This call is outside the current window', + 'details.input': 'Input', + 'details.output': 'Output', + 'details.running': 'Running…', + 'chat.loadingHistory': 'Loading history…', + 'chat.loadError': 'Failed to load history: {message} ({code})', + 'chat.loadOlder': 'Load earlier', + 'chat.toBottom': 'Back to bottom', + 'fileOpen.title': 'Couldn’t open file', + 'fileOpen.unknown': 'Couldn’t open this file', + 'fileOpen.folderTitle': 'Couldn’t open folder', + 'fileOpen.folderUnknown': 'Couldn’t open this folder', + 'message.extraBlock': 'Extra content block', + 'message.contextInjection': 'Context injection', + 'message.contextRecall': 'Session recall', + 'message.referenceSummary': 'Referenced session · {labels}', + 'message.referenceSeparator': ', ', + 'message.context.instructions.loaded': 'loaded', + 'message.context.instructions.added': 'added', + 'message.context.instructions.updated': 'updated', + 'message.context.instructions.removed': 'removed', + 'message.context.catalog.replaced': 'Replacement catalog', + 'message.context.catalog.more': '… {count} more', + 'message.context.snapshot.supersedes': 'Supersedes earlier snapshots', + 'message.context.relay.from': 'From session {session}', + 'message.context.recall.counts': '{retained} kept · {omitted} omitted', + 'message.context.recall.truncated': 'truncated', + 'message.compaction': 'Context compacted', + 'message.compaction.running': 'Compacting context…', + 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', + 'message.compaction.expand': 'View compaction summary', + 'message.compaction.unavailable': 'Compaction summary unavailable', + 'message.unknownSurface': 'Unknown surface event: {type}', + 'message.unknownBlock': 'Unknown content block', + 'message.stopped': 'Stopped', + 'message.branch': 'Branch into a new conversation', + 'message.branchUnavailable': 'Available only on the last message of a completed turn', + 'message.retry.active': 'Retrying model request', + 'message.retry.cancelled': 'Model request retry cancelled', + 'message.retry.started': 'Retried model request', + 'message.retry.scheduled': 'Waiting to retry model request', + 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': 'Retry delay: ', + 'message.retry.failure': 'Failure reason: ', + 'message.turnError': 'This turn failed', + 'message.maxTokens': 'Output token limit reached', + 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', + 'message.ranFor': 'Ran for {duration}', + 'message.ttft': 'TTFT {seconds}s', + 'message.tokensPerSecond': '{tps} tok/s', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'command.running': 'Running…', + 'command.failed': 'Command failed', + 'command.done': 'Completed', + 'command.title': 'Command', + 'row.running': 'Running', + 'row.failed': 'Failed', + 'json.truncated': '… truncated, {total} characters total', + 'clock.md': '{m}/{d}', + 'clock.ymd': '{y}-{m}-{d}', +} satisfies Record diff --git a/packages/client/runtime/src/client/sessions/conversation-context.ts b/packages/client/ui-chat/src/client/model/conversation-context.ts similarity index 86% rename from packages/client/runtime/src/client/sessions/conversation-context.ts rename to packages/client/ui-chat/src/client/model/conversation-context.ts index 20a8bbcadd..7f8116ffcc 100644 --- a/packages/client/runtime/src/client/sessions/conversation-context.ts +++ b/packages/client/ui-chat/src/client/model/conversation-context.ts @@ -1,5 +1,5 @@ -import type { ConversationNode } from './conversation.ts' -import type { ConversationPromptSnapshot } from './request-inspection.ts' +import type { ConversationNode } from '../contract/snapshot.ts' +import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' /** Operation that started a new append-only model context. */ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' diff --git a/packages/client/runtime/src/client/sessions/steering-history.ts b/packages/client/ui-chat/src/client/model/steering-history.ts similarity index 100% rename from packages/client/runtime/src/client/sessions/steering-history.ts rename to packages/client/ui-chat/src/client/model/steering-history.ts diff --git a/packages/client/runtime/src/client/sessions/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts similarity index 99% rename from packages/client/runtime/src/client/sessions/tool-call-tree.ts rename to packages/client/ui-chat/src/client/model/tool-call-tree.ts index 4507b7a947..705bc481dc 100644 --- a/packages/client/runtime/src/client/sessions/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -2,7 +2,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-tools/types' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, -} from './conversation.ts' +} from '../contract/snapshot.ts' interface ProjectedBlock { source: ToolCallBlock diff --git a/packages/client/ui-chat/src/client/stores.ts b/packages/client/ui-chat/src/client/stores.ts new file mode 100644 index 0000000000..7ff6b80ad7 --- /dev/null +++ b/packages/client/ui-chat/src/client/stores.ts @@ -0,0 +1,20 @@ +/** Per-Session Chat selection store shared by the transcript and details panel. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { ChatStoreState, SelectionTarget } from './contract/store.ts' + +type ChatActions = { + select: (draft: ChatStoreState, target: SelectionTarget | null) => void +} + +/** + * Create the Chat selection store handle. + * @returns a handle instantiated once per rendered Session scope. + */ +export function createChatStore(): EngineStoreHandle { + return defineStore({ + init: (): ChatStoreState => ({ selection: null }), + actions: { + select: (draft, target: SelectionTarget | null) => { draft.selection = target }, + }, + }) +} diff --git a/packages/client/ui-chat/src/css-modules.d.ts b/packages/client/ui-chat/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-chat/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts new file mode 100644 index 0000000000..4c47106067 --- /dev/null +++ b/packages/client/ui-chat/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser-only Chat UI target. */ + +/** Provides no Host-side behavior. */ +export function apply(): void {} diff --git a/packages/client/ui-chat/src/invariant.ts b/packages/client/ui-chat/src/invariant.ts new file mode 100644 index 0000000000..d9e06d4f59 --- /dev/null +++ b/packages/client/ui-chat/src/invariant.ts @@ -0,0 +1,21 @@ +/** Package-owned invariant companion for the Chat UI target. */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-chat' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-chat-invariant' +/** Service required before the companion reserves package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: Conversation and Slot registration enforce Chat target consistency. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx new file mode 100644 index 0000000000..0fd861a14a --- /dev/null +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */ +import { describe, expect, it, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { + SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' +import { + apply as applyConversation, inject as injectConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + apply as applyChat, inject as injectChat, type ChatViewInjected, type DetailsInjected, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createChatStore } from '../src/client/stores.ts' + +usePinnedBrowserLanguages('zh-CN') + +const ROOT = 'root-1' as SessionId +const ATTACHMENT = { + attachmentId: AttachmentId('image-1'), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, +} as const + +type ChatInstance = ReturnType['create']> +type ChatActions = ChatInstance['actions'] + +function sessionFakeFor() { + return { + loadOlder: vi.fn(() => Promise.resolve()), + readAttachment: vi.fn(() => Promise.resolve({ + ok: true, + value: { attachment: ATTACHMENT, data: Uint8Array.of(1) }, + })), + prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + } satisfies SessionBehaviorOverrides +} + +async function bench() { + const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } + runtime.ctx.provide('layout', layout as never) + const openPath = vi.fn<(path: string) => Promise>(async () => {}) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => ROOT), + openPath, + } as never) + const session = sessionFakeFor() + await runtime.sessions.add({ + id: ROOT, + summary: { title: 'R', displayTitle: 'R', cwd: '/proj' }, + session, + }, { current: false }) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, + }, (_props: { renderSlot?: unknown }) => null) + await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) + runtime.renderRoot() + + const chatViewApi = (id: SessionId) => { + const entry = runtime.slots.entries('conversation.view')[0]! + const instance = runtime.storeOf('conversation.view', id) as ChatInstance + const injected = (entry.inject as unknown as ( + sessionId: SessionId, + actions: ChatActions, + ) => ChatViewInjected)(id, instance.actions) + return { instance, injected } + } + return { runtime, layout, openPath, session, chatViewApi } +} + +describe('Chat inject API', () => { + it('loads older history and forks through the Session Controller', async () => { + const b = await bench() + const { injected } = b.chatViewApi(ROOT) + injected.loadOlder() + expect(b.session.loadOlder).toHaveBeenCalledOnce() + + injected.forkAt(17) + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) + }) + expect(b.runtime.sessions.calls).toContainEqual({ + method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }], + }) + + const fork = vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork failed')) + injected.forkAt(18) + await vi.waitFor(() => { + expect(fork).toHaveBeenCalledWith({ sessionId: ROOT, atSeq: 18, increaseTitle: true }) + }) + await b.runtime.dispose() + }) + + it('writes Chat selection before opening details', async () => { + const b = await bench() + const { instance, injected } = b.chatViewApi(ROOT) + injected.openDetails({ turnSeq: 2, callId: 'c1' }) + expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' }) + expect(b.layout.openDetails).toHaveBeenCalledOnce() + expect(b.runtime.storeOf('details', ROOT)).toBe(instance) + expect(b.runtime.storeOf('conversation.session', ROOT)).not.toBe(instance) + await b.runtime.dispose() + }) + + it('resolves file paths against the Session cwd and preserves failures', async () => { + const b = await bench() + const { injected } = b.chatViewApi(ROOT) + await injected.openFile('src/a.ts') + expect(b.openPath).toHaveBeenCalledWith('/proj/src/a.ts') + + b.openPath.mockRejectedValueOnce(new Error('xdg-open is not available')) + await expect(injected.openFile('src/b.ts')).rejects.toThrow('xdg-open is not available') + await b.runtime.dispose() + }) + + it('fails loud when a Chat View inject resolves no Session', async () => { + const b = await bench() + const entry = b.runtime.slots.entries('conversation.view')[0]! + const injectView = entry.inject as unknown as ( + sessionId: SessionId, + actions: ChatActions, + ) => ChatViewInjected + expect(() => injectView('never-listed' as SessionId, {} as ChatActions)) + .toThrow(/unknown session/) + await b.runtime.dispose() + }) + + it('closes details while sharing selection through the Chat store', async () => { + const b = await bench() + const entry = b.runtime.slots.entries('details')[0]! + const injected = (entry.inject as unknown as () => DetailsInjected)() + expect(Object.keys(injected)).toEqual(['closeDetails']) + injected.closeDetails() + expect(b.layout.closeDetails).toHaveBeenCalledOnce() + expect(b.runtime.storeOf('details', ROOT)).toBe(b.runtime.storeOf('conversation.view', ROOT)) + await b.runtime.dispose() + }) + + it('owns image loading, scroll memory, and optional closing-file mentions', async () => { + const b = await bench() + const { injected } = b.chatViewApi(ROOT) + const owner = {} as never + + expect(injected.fileMentions(owner)).toBeUndefined() + const mentions = { resolve: vi.fn() } as never + const forClosing = vi.fn(() => mentions) + b.runtime.ctx.provide('chatFileMentions', { forClosing } as never) + expect(injected.fileMentions(owner)).toBe(mentions) + expect(forClosing).toHaveBeenCalledWith(owner) + + expect(injected.chatScroll.read()).toBeNull() + const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 } + injected.chatScroll.save(position) + expect(injected.chatScroll.read()).toEqual(position) + injected.chatScroll.save(null) + expect(injected.chatScroll.read()).toBeNull() + + await expect(injected.loadImage(ATTACHMENT)).resolves.toEqual(expect.any(String)) + expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId) + await b.runtime.dispose() + }) +}) diff --git a/packages/client/ui-chat/tests/approval-command.client.spec.tsx b/packages/client/ui-chat/tests/approval-command.client.spec.tsx new file mode 100644 index 0000000000..5287b68272 --- /dev/null +++ b/packages/client/ui-chat/tests/approval-command.client.spec.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import type { ChatSnapshot, UseChat } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ApprovalCommand, commandOf } from '../src/client/chat/ApprovalCommand.tsx' +import { apply as nodeApply } from '../src/index.ts' +import * as ChatInvariant from '../src/invariant.ts' + +function props( + nodes: readonly unknown[], + callId = 'call-1', +): PropsRuntime<'conversation.approval.detail'> { + const snapshot = { + nodes: { values: () => nodes }, + } as unknown as ChatSnapshot + const useChat = ((selector: (value: ChatSnapshot) => unknown) => selector(snapshot)) as UseChat + return { callId, useChat } as PropsRuntime<'conversation.approval.detail'> +} + +describe('commandOf', () => { + it('accepts only a string command from valid JSON arguments', () => { + expect(commandOf(undefined)).toBeUndefined() + expect(commandOf({ callId: 'c1', argsRaw: '{' })).toBeUndefined() + expect(commandOf({ callId: 'c1', argsRaw: '{}' })).toBeUndefined() + expect(commandOf({ callId: 'c1', argsRaw: '{"command":42}' })).toBeUndefined() + expect(commandOf({ callId: 'c1', argsRaw: '{"command":"pnpm test"}' })).toBe('pnpm test') + }) +}) + +describe('ApprovalCommand', () => { + it('renders the running correlated Tool command', () => { + render() + + expect(screen.getByText('pnpm test')).toBeTruthy() + }) + + it('omits absent, uncorrelated, and settled Tool calls', () => { + const { container, rerender } = render() + expect(container.textContent).toBe('') + + rerender() + expect(container.textContent).toBe('') + }) +}) + +describe('ui-chat package entries', () => { + it('keeps the Host half inert and registers the invariant companion', async () => { + expect(() => { nodeApply() }).not.toThrow() + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + + await expect(ctx.plugin(ChatInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx new file mode 100644 index 0000000000..eaf64ab256 --- /dev/null +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { + chatSnapshot, SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, +} from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + apply as applyConversation, inject as injectConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + apply as applyChat, EMPTY_CHAT_SNAPSHOT, inject as injectChat, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { + ChatNodeTurnDataInjected, ChatSnapshot, UseChat, +} from '@deepseek-ai/dsh-client-ui-chat/client' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationTurnDataMap { + metric: number + } +} + +usePinnedBrowserLanguages('zh-CN') + +const SID = 'session-1' as SessionId + +async function bench() { + const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => SID), + openPath: vi.fn(async () => {}), + } as never) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'details': { kind: 'single', scope: 'session' }, + 'conversation.approval.detail': { kind: 'single', scope: 'session' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, (_props: { renderSlot?: unknown }) => null) + const conversation = await runtime.mount({ + inject: [...injectConversation], + apply: applyConversation, + }) + const provide = vi.spyOn(runtime.ctx.uiSession, 'provide') + const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat }) + const sourceDescriptor = provide.mock.calls[0]?.[0] + if (sourceDescriptor === undefined) throw new Error('ui-chat did not provide its standard source') + return { runtime, conversation, chat, sourceDescriptor } +} + +function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') { + return (runtime.slots.entries(key)[0] as { store?: unknown } | undefined)?.store +} + +describe('Chat apply wiring', () => { + it('contributes Chat View, node renderers, stats, and details', async () => { + const b = await bench() + const views = b.runtime.slots.entries('conversation.view') + expect(views.map(row => row.options.id)).toEqual(['chat']) + expect(resolveSlotLabel(views[0]?.options.label)).toBe('对话') + expect(b.runtime.slots.spec('conversation.chat.node')) + .toMatchObject({ kind: 'keyed', scope: 'session' }) + expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id)) + .toEqual(['stats']) + expect(b.runtime.slots.entries('details')).toHaveLength(1) + await b.runtime.dispose() + }) + + it('shares one Chat store while keeping it distinct from Conversation state', async () => { + const b = await bench() + const conversationStore = storeOf(b.runtime, 'conversation.session') + const chatStore = storeOf(b.runtime, 'conversation.view') + expect(storeOf(b.runtime, 'conversation.session.header')).toBe(conversationStore) + expect(storeOf(b.runtime, 'details')).toBe(chatStore) + expect(chatStore).toBeDefined() + expect(chatStore).not.toBe(conversationStore) + await b.runtime.dispose() + }) + + it('removes only Chat contributions when Chat unloads', async () => { + const b = await bench() + await b.chat.dispose() + expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0) + expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined() + expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.ctx.get('uiConversation')).toBeDefined() + await b.runtime.dispose() + }) + + it('keeps the Chat standard source total while its target enters and leaves', async () => { + const b = await bench() + await b.runtime.sessions.add({ id: SID }, { current: false }) + const binding = b.runtime.sessions.binding(SID) + if (binding === undefined) throw new Error('Chat source test Session binding is unavailable') + const resolveSource = (owner: SessionBinding): ObservableSnapshot => { + const contribution = b.sourceDescriptor.resolve(owner) as { + hooks: { chat: ObservableSnapshot } + } + return contribution.hooks.chat + } + const source = b.runtime.ctx.uiSession.adapter.resolve(SID)!.hooks.chat as + ObservableSnapshot + expect(resolveSource(binding)).toBe(source) + expect(resolveSource(binding)).toBe(source) + const listener = vi.fn() + const off = source.subscribe(listener) + + expect(source.getSnapshot()).toBeDefined() + await b.chat.dispose() + expect(source.getSnapshot()).toBe(EMPTY_CHAT_SNAPSHOT) + + off() + await b.runtime.dispose() + }) + + it('binds Turn data through the Chat selector hook for Turn and Step locations', async () => { + const b = await bench() + const spec = b.runtime.slots.spec('conversation.chat.node') as unknown as { + inject: ChatNodeTurnDataInjected + } + let snapshot: ChatSnapshot = chatSnapshot() + const useChat = ((selector: (value: ChatSnapshot) => unknown) => selector(snapshot)) as UseChat + const useTurnData = spec.inject.hooks.turnData( + { useChat } as Parameters[0], + 'node-1', + ) + const data = { get: (key: string) => key === 'metric' ? 42 : undefined } + const turn = { data } + + snapshot = chatSnapshot({ + nodes: { get: () => ({ location: { kind: 'turn', turn } }), values: () => [] } as never, + }) + expect(useTurnData('metric')).toBe(42) + snapshot = chatSnapshot({ + nodes: { get: () => ({ location: { kind: 'step', turn } }), values: () => [] } as never, + }) + expect(useTurnData('metric')).toBe(42) + snapshot = chatSnapshot({ + nodes: { get: () => ({ location: { kind: 'session' } }), values: () => [] } as never, + }) + expect(useTurnData('metric')).toBeUndefined() + snapshot = chatSnapshot({ + nodes: { get: () => undefined, values: () => [] }, + }) + expect(useTurnData('metric')).toBeUndefined() + + await b.runtime.dispose() + }) +}) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx similarity index 99% rename from packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx rename to packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 7edb6a2215..8cae81f191 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -7,7 +7,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ChatConversationViewNode, ConversationNode, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-chat/client' import type { ChatNodeViewProps } from '../src/client/contract/slots.ts' import { formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, @@ -17,8 +17,8 @@ import { UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' -import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' -import { zh } from '../src/client/locales.ts' +import { StatsLine } from '../src/client/chat/StatsLine.tsx' +import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ @@ -1022,12 +1022,12 @@ describe('small branch tails', () => { const nodes = [{ kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 }, }] as const - const snap = { chat: chatSnapshotFixture({ nodes }), nodes } + const snap = chatSnapshotFixture({ nodes }) const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( key === 'tokenUsage' ? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 } : undefined} diff --git a/packages/client/ui-conversation/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts similarity index 96% rename from packages/client/ui-conversation/tests/chat-snapshot-fixture.client.ts rename to packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index fb343f0f68..a6033c4c8c 100644 --- a/packages/client/ui-conversation/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -1,10 +1,12 @@ import type { AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode, - ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, ConversationLocationDataStore, - ConversationTurnDataMap, LegacyConversationSlice, PartialAssistant, RunningToolCall, - ToolCallBlock, TurnLocation, -} from '@deepseek-ai/dsh-client-runtime/client' -import { deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts' + ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, LegacyConversationSlice, + PartialAssistant, RunningToolCall, ToolCallBlock, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { + ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts' const EMPTY: readonly never[] = [] diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx similarity index 90% rename from packages/client/ui-conversation/tests/chat-stats.client.spec.tsx rename to packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 5592c24730..62d0e553b8 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -3,15 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { - AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { EMPTY_CONVERSATION_VIEWS } from '@deepseek-ai/dsh-client-runtime/client' + AssistantMessageNode, ChatSnapshot, LegacyConversationSlice, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' -import { en, zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' const t: StatsLineProps['t'] = makeTranslate(zh, commonZh) @@ -32,48 +31,19 @@ afterEach(() => { vi.useRealTimers() }) -const SID = 's1' as SessionId - const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({ kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], ...(usage === undefined ? {} : { usage }), }) -function snapshotBase(): ConversationSnapshot { - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, - } -} +type ChatUpdate = Partial -function makeSource(init?: Partial) { - const initial = { ...snapshotBase(), ...init } - let snap: ConversationSnapshot = { - ...initial, - chat: init?.chat ?? chatSnapshotFixture({ - nodes: initial.nodes, - partial: initial.partial, - runningCalls: initial.runningCalls, - turnTimings: initial.turnTimings, - turnEnds: initial.turnEnds, - }), - } +function makeSource(init: ChatUpdate = {}) { + let snap = chatSnapshotFixture(init) const subs = new Set<() => void>() return { - set: (next: Partial) => { - const merged = { ...snap, ...next } - snap = { - ...merged, - chat: next.chat ?? (next.nodes === undefined ? snap.chat : chatSnapshotFixture({ - nodes: merged.nodes, - partial: merged.partial, - runningCalls: merged.runningCalls, - turnTimings: merged.turnTimings, - turnEnds: merged.turnEnds, - })), - } + set: (next: ChatUpdate) => { + snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap) for (const fn of [...subs]) fn() }, source: { @@ -182,10 +152,10 @@ describe('StatsLine', () => { } function props( - source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }, + source: { getSnapshot(): ChatSnapshot; subscribe(fn: () => void): () => void }, values: Record = { tokenUsage: USAGE }, ): StatsLineProps { - return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn } + return { useChat: bindSnapshotSelector(source), useProjection: projections(values), t: tEn } } function tokenUsage(cacheReadTokens: number, uncachedInputTokens: number) { @@ -413,7 +383,6 @@ describe('StatsLine', () => { // Chunk frames swap partial only; nodes keeps its reference (object-layer contract). act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) }) act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) }) - act(() => { set({ running: true }) }) expect(renders).toBe(before) }) }) diff --git a/packages/client/ui-chat/tests/chat-store.client.spec.ts b/packages/client/ui-chat/tests/chat-store.client.spec.ts new file mode 100644 index 0000000000..efdda72dae --- /dev/null +++ b/packages/client/ui-chat/tests/chat-store.client.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { createChatStore } from '../src/client/stores.ts' + +describe('createChatStore', () => { + it('starts without a selected Chat target', () => { + const store = createChatStore().create() + expect(store.store.getSnapshot()).toEqual({ selection: null }) + }) + + it('selects and clears one Chat details target', () => { + const store = createChatStore().create() + store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) + expect(store.store.getSnapshot().selection) + .toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) + store.actions.select(null) + expect(store.store.getSnapshot().selection).toBeNull() + }) + + it('creates independent instances', () => { + const handle = createChatStore() + const first = handle.create() + const second = handle.create() + first.actions.select({ turnSeq: 1 }) + expect(second.store.getSnapshot().selection).toBeNull() + }) +}) diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx similarity index 90% rename from packages/client/ui-conversation/tests/chat-view.client.spec.tsx rename to packages/client/ui-chat/tests/chat-view.client.spec.tsx index b2072fb220..57186c03f6 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -4,23 +4,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useEffect } from 'react' import type { - AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, - ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode, - TurnMaxTokensNode, UserMessageNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, PendingWait, -} from '@deepseek-ai/dsh-client-runtime/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' + AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot, + ChatViewSlotProps, CommandNode, CompactionSummaryNode, ConversationNode, + LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, + ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, + UseChatNodeTurnData, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' import type { - ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData, -} from '@deepseek-ai/dsh-client-ui-conversation/client' + SessionListState, SessionSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' -import { zh } from '../src/client/locales.ts' +import { zh } from '../src/client/locale.ts' import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' import { @@ -44,32 +47,54 @@ beforeEach(() => { const SID = 's1' as SessionId type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } -function snapshotBase(): ConversationSnapshot { +function sessionSnapshot(overrides: Partial = {}): SessionSnapshot { return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [], - turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, + sessionId: SID, + queue: [], + running: false, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + subagent: null, + lastAgentError: null, + promptAttempted: true, + awaitingFirstTurn: false, + ...overrides, } } -/** Scripted snapshot source: set() swaps the top-level object like the real Session. */ -function makeSource(init?: Partial) { - const initial = { ...snapshotBase(), ...init } - let snap: ConversationSnapshot = { - ...initial, - chat: init?.chat ?? chatSnapshotFixture(initial), - } +/** Scripted Session source: set() swaps the top-level object like the real Controller binding. */ +function makeSessionSource(init: Partial = {}) { + let snap = sessionSnapshot(init) const subs = new Set<() => void>() return { - set: (next: Partial) => { - const merged = { ...snap, ...next } - snap = { - ...merged, - chat: Object.hasOwn(next, 'chat') && next.chat !== undefined - ? next.chat - : chatSnapshotFixture(merged, snap.chat), - } + set: (next: Partial) => { + snap = { ...snap, ...next } + for (const fn of [...subs]) fn() + }, + source: { + getSnapshot: () => snap, + subscribe: (fn: () => void) => { + subs.add(fn) + return () => subs.delete(fn) + }, + }, + } +} + +type ChatSlice = Partial + +/** Scripted Chat target source, independent from Session lifecycle state. */ +function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) { + let snap = snapshot ?? chatSnapshotFixture(init) + const subs = new Set<() => void>() + return { + set: (next: ChatSlice) => { + snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap) for (const fn of [...subs]) fn() }, source: { @@ -139,19 +164,23 @@ function emptySessions() { } function emptyWorkspaces() { - const store = createSnapshotStore({ + const store = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) } -function makeHarness(init?: Partial) { - const { set, source } = makeSource(init) +function makeHarness( + chatSlice: ChatSlice = {}, + sessionInit: Partial = {}, + chatSnapshot?: ChatSnapshot, +) { + const session = makeSessionSource(sessionInit) + const chatSource = makeChatSource(chatSlice, chatSnapshot) const openDetails = vi.fn<(t: SelectionTarget) => void>() const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const loadOlder = vi.fn() - const inspectCall = vi.fn<(callId: string) => void>() + const openView = vi.fn<(view: string, focus: string) => void>() // In-memory scroll memory matching the apply.ts per-session map contract. let savedScroll: ReturnType = null const chatScroll: ChatViewSlotProps['chatScroll'] = { @@ -183,8 +212,8 @@ function makeHarness(init?: Partial) { if (key !== 'conversation.chat.node') return opts?.fallback ?? null const nodeOwner = owner as RoutedChatNodeOwner const nodeKey = opts?.hookContext as string | undefined - const useTurnData: UseChatNodeTurnData = dataKey => props.useSession((snapshot) => { - const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location + const useTurnData: UseChatNodeTurnData = dataKey => props.useChat((snapshot) => { + const location = nodeKey === undefined ? undefined : snapshot.nodes.get(nodeKey)?.location return location?.kind === 'turn' || location?.kind === 'step' ? location.turn.data.get(dataKey) : undefined @@ -256,12 +285,18 @@ function makeHarness(init?: Partial) { } }) as unknown as ChatViewSlotProps['renderSlot'] // SessionProvider seat arrives with the session-scope child declaration; - // ChatView never invokes it (render-prop pass-through stub). - const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)} + // ChatView never invokes it (pass-through stub). + const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: ChatViewSlotProps = { sessionId: SID, - useSession: bindSnapshotSelector(source), + useSession: bindSnapshotSelector(session.source), + useChat: bindSnapshotSelector(chatSource.source), + useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)), + useTrajectory: (() => { throw new Error('unused') }), useSessions: emptySessions(), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined), useInput: (() => { throw new Error('unused') }), @@ -276,11 +311,13 @@ function makeHarness(init?: Partial) { actions: chat.actions, renderSlot, SessionProvider: SessionProviderStub, + viewRequest: null, + openView, + completeViewRequest: () => {}, openDetails, openFile, loadOlder, loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - inspectCall, chatScroll, forkAt, // Absent-service default; mention tests override with a real resolver. @@ -289,7 +326,8 @@ function makeHarness(init?: Partial) { } const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) } return { - set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, + setSession: session.set, setChat: chatSource.set, ChatView, props, + openDetails, openFile, loadOlder, openView, chatScroll, forkAt, setSelection, toolOwners, } } @@ -381,7 +419,10 @@ describe('ChatView', () => { }) it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => { - const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true }) + const h = makeHarness( + { nodes: [user(9, 'first visible'), user(10, 'next visible')] }, + { hasMore: true }, + ) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement @@ -408,7 +449,9 @@ describe('ChatView', () => { readerScroll(scroller, 90) Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true }) nextTop = 560 - act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) }) + act(() => { + h.setChat({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) + }) expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift }) @@ -461,7 +504,10 @@ describe('ChatView', () => { preview: 'later', text: 'later', } - const h = makeHarness({ nodes: [assistant(1, 'working')], queue: [queued, pending], running: true }) + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { queue: [queued, pending], running: true }, + ) const view = render() expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull() @@ -475,8 +521,8 @@ describe('ChatView', () => { & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) act(() => { - h.set({ - queue: [queued], + h.setSession({ queue: [queued] }) + h.setChat({ nodes: [ assistant(1, 'working'), { @@ -497,7 +543,8 @@ describe('ChatView', () => { expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull() act(() => { - h.set({ running: false, turnEnds: new Map([[1, 3]]) }) + h.setSession({ running: false }) + h.setChat({ turnEnds: new Map([[1, 3]]) }) }) // The Turn Tail belongs to the closed Turn, independently of a later // steering bubble's placement in the Chat list. @@ -518,13 +565,11 @@ describe('ChatView', () => { text: 'same steering', } const h = makeHarness({ - queue: [pending], nodes: [{ kind: 'user', seq: 2, time: 2_000, content: pending.content, source: null, }], - running: true, - }) + }, { queue: [pending], running: true }) const view = render() expect(view.getAllByText('same steering')).toHaveLength(2) @@ -539,35 +584,36 @@ describe('ChatView', () => { provenance: { role: 'inject', label: null }, form: null, } as const satisfies ConversationNode - const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) + const h = makeHarness({ nodes: [user(1, 'try'), retryNode] }, { running: true }) const view = render() const disclosure = view.container.querySelector('details') as HTMLDetailsElement expect(disclosure.dataset.active).toBe('true') expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') act(() => { - h.set({ nodes: [user(1, 'try'), nextRetry] }) + h.setChat({ nodes: [user(1, 'try'), nextRetry] }) }) expect(within(disclosure).getAllByRole('status')).toHaveLength(1) expect(view.container.querySelector('details')).toBe(disclosure) expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') act(() => { - h.set({ + h.setChat({ nodes: [ user(1, 'try'), { ...nextRetry, retryState: 'started' }, context, assistant(5, 'done'), ], - running: false, }) + h.setSession({ running: false }) }) expect(disclosure.dataset.active).toBeUndefined() expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s') act(() => { - h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true }) + h.setChat({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }] }) + h.setSession({ running: true }) }) const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement expect(cancelledDisclosure.dataset.active).toBeUndefined() @@ -599,7 +645,8 @@ describe('ChatView', () => { nodes: [toolResult(3, 'a')], }) render() - expect(h.toolOwners[0]?.inspectCall).toBe(h.inspectCall) + h.toolOwners[0]?.inspectCall('a') + expect(h.openView).toHaveBeenCalledWith('trajectory', 'a') }) it('shows assistant IconActions only on the last content message of each turn', () => { @@ -624,7 +671,6 @@ describe('ChatView', () => { it('withholds assistant IconActions while the turn is still running', () => { const h = makeHarness({ - running: true, runningCalls: [runningCall('a')], nodes: [ user(1, 'first'), @@ -634,7 +680,7 @@ describe('ChatView', () => { ], // Boundary seqs follow the log: a turn/end is strictly after its own nodes. turnEnds: new Map([[1, 3]]), - }) + }, { running: true }) const view = render() // 2 user + the settled turn-1 tail, which keeps its seat while a later // turn runs; turn 2's narration stays chrome-free while its tool runs, so @@ -642,7 +688,10 @@ describe('ChatView', () => { expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3) expect(view.getByText('mid-turn text')).toBeTruthy() // turn/end lands: the same node becomes the settled answer and takes the seat. - act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) }) + act(() => { + h.setSession({ running: false }) + h.setChat({ runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) + }) expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) }) @@ -695,8 +744,7 @@ describe('ChatView', () => { nodes: [user(1, 'hi'), settled], turnTimings: new Map([[1, { startTime: 1_000 }]]), turnEnds: new Map(), - running: true, - }) + }, { running: true }) const view = render() expect(view.queryByText(/首 token|tok\/s/)).toBeNull() }) @@ -749,7 +797,7 @@ describe('ChatView', () => { getStep: (turn: number, step: number) => base.locations.getStep(turn, step), }, } - const h = makeHarness({ chat }) + const h = makeHarness({}, {}, chat) const view = render() const branch = view.getByRole('button', { name: '在新对话中分支' }) expect(branch.getAttribute('aria-disabled')).toBe('true') @@ -786,13 +834,13 @@ describe('ChatView', () => { expect(literal.querySelector('h1')).toBeNull() act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: markdown }] } }) }) expect(view.container.querySelectorAll('h1')).toHaveLength(2) expect(view.container.querySelector('[data-streaming="true"] h1')?.textContent).toBe('Rendered') act(() => { - h.set({ + h.setChat({ nodes: [user(1, markdown), assistant(2, markdown), assistant(3, markdown)], partial: null, }) @@ -801,7 +849,7 @@ describe('ChatView', () => { expect(view.container.querySelector('[data-streaming="true"]')).toBeNull() act(() => { - h.set({ + h.setChat({ nodes: [ user(1, markdown), assistant(2, markdown), @@ -821,10 +869,10 @@ describe('ChatView', () => { const tool = view.getByTestId('tool-seat-a') const beforeHtml = tool.innerHTML act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } }) }) act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } }) }) expect(view.getByText('streaming… more')).toBeTruthy() expect(view.getByTestId('tool-seat-a')).toBe(tool) @@ -848,10 +896,10 @@ describe('ChatView', () => { expect(view.getByTestId('counting-row')).toBeTruthy() const afterMount = rowRenders act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1' }] } }) }) act(() => { - h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } }) + h.setChat({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'chunk1 chunk2' }] } }) }) expect(rowRenders).toBe(afterMount) }) @@ -865,7 +913,7 @@ describe('ChatView', () => { }) it('hands running calls to a live Tool group', () => { - const h = makeHarness({ runningCalls: [runningCall('r1')], running: true }) + const h = makeHarness({ runningCalls: [runningCall('r1')] }, { running: true }) const view = render() expect(view.getByTestId('tool-seat-r1')).toBeTruthy() expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' }) @@ -891,8 +939,7 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(4, 'later')], runningCalls: [runningCall('r1')], - running: true, - }) + }, { running: true }) h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { const routed = owner as RoutedChatNodeOwner return key === 'conversation.chat.node' && routed.node.kind === 'tool-call' @@ -906,11 +953,11 @@ describe('ChatView', () => { expect(mounted).toHaveBeenCalledTimes(1) act(() => { - h.set({ + h.setChat({ nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')], runningCalls: [], - running: false, }) + h.setSession({ running: false }) }) expect(view.getByTestId('stateful-tool')).toBe(tool) @@ -923,16 +970,17 @@ describe('ChatView', () => { it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => { const startTime = Date.now() - 125_000 const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 } - const h = makeHarness({ - nodes: [trigger], turnTimings: new Map([[1, { startTime }]]), running: true, - }) + const h = makeHarness( + { nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) }, + { running: true }, + ) const view = render() // Freshly mounted (as after a reload) yet already past the 15s gate. const status = view.getByRole('status') expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/) expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull() act(() => { - h.set({ queue: [{ + h.setSession({ queue: [{ id: 'steering-occurrence' as never, messageId: 'steering-message' as never, placement: 'steering', @@ -964,7 +1012,8 @@ describe('ChatView', () => { expect(owner.openFile).not.toBe(h.openFile) owner.openFile('src/a.ts') expect(h.openFile).toHaveBeenCalledWith('src/a.ts') - expect(owner.inspectCall).toBe(h.inspectCall) + owner.inspectCall('a') + expect(h.openView).toHaveBeenCalledWith('trajectory', 'a') }) it('shows a Host open refusal with the reason and retries the same path', async () => { @@ -1069,7 +1118,10 @@ describe('ChatView', () => { }) it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { - const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true }) + const h = makeHarness( + { nodes: [user(5, 'later'), assistant(6, 'a')] }, + { hasMore: true }, + ) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement // jsdom has no layout: fake the metrics the anchor math reads. @@ -1085,15 +1137,21 @@ describe('ChatView', () => { fireEvent.click(view.getByText('加载更早')) Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true }) anchoredTop = 700 - act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) }) + act(() => { + h.setChat({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) + }) expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift // A new trailing user bubble (own words) force-scrolls to the bottom. - act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) }) + act(() => { + h.setChat({ + nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')], + }) + }) expect(scroller.scrollTop).toBe(1600) }) it('back-to-bottom cancels an in-flight paging anchor', () => { - const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true }) + const h = makeHarness({ nodes: [user(9, 'late')] }, { hasMore: true }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true }) @@ -1102,7 +1160,7 @@ describe('ChatView', () => { fireEvent.click(view.getByText('加载更早')) fireEvent.click(view.getByLabelText('回到底部')) Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true }) - act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) }) + act(() => { h.setChat({ nodes: [assistant(2, 'older'), user(9, 'late')] }) }) expect(scroller.scrollTop).toBe(1_300) expect(h.chatScroll.read()).toBeNull() }) @@ -1117,7 +1175,9 @@ describe('ChatView', () => { const backButton = view.getByLabelText('回到底部') expect(backButton).toBeTruthy() // Streaming growth must NOT drag a scrolled-away reader down. - act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) }) + act(() => { + h.setChat({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) + }) expect(scroller.scrollTop).toBe(100) fireEvent.click(backButton) expect(scroller.scrollTop).toBe(1000) @@ -1143,7 +1203,7 @@ describe('ChatView', () => { expect(h.chatScroll.read()).toBeNull() metrics.setHeight(1_200) - act(() => { h.set({ running: true }) }) + act(() => { h.setSession({ running: true }) }) expect(scroller.scrollTop).toBe(900) }) @@ -1319,40 +1379,26 @@ describe('ChatView', () => { }) it('paging button loads older and shows its busy label', () => { - const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true }) + const h = makeHarness({ nodes: [user(5, 'later')] }, { hasMore: true }) const view = render() fireEvent.click(view.getByText('加载更早')) expect(h.loadOlder).toHaveBeenCalledTimes(1) - act(() => { h.set({ loadingOlder: true }) }) + act(() => { h.setSession({ loadingOlder: true }) }) expect(view.getByText('加载中…')).toBeTruthy() }) it('shows open error and loading states', () => { - const h = makeHarness({ + const h = makeHarness({}, { openState: 'error', openError: { code: 'internal', message: 'boom' } as never, }) const view = render() expect(view.getByText(/历史加载失败:boom/)).toBeTruthy() - const loading = makeHarness({ openState: 'loading' }) + const loading = makeHarness({}, { openState: 'loading' }) const lv = render() expect(lv.getByText('载入历史…')).toBeTruthy() }) - it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => { - const h = makeHarness({ - pending: [ - new PendingWait('approval', RpcId('r1'), SID, - { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()), - new PendingWait('question', RpcId('r2'), SID, - { questions: [{ id: 'q1', question: '选择' }] }, vi.fn()), - ], - }) - const view = render() - expect(view.queryByText(/等待回答/)).toBeNull() - expect(view.queryByText(/等待审批/)).toBeNull() - }) - it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => { // Settled success: the bare command name is the title, the outcome text // the summary — neither the dispatched `/` nor its arguments reach the row @@ -1403,7 +1449,7 @@ describe('ChatView', () => { expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() act(() => { - h.set({ + h.setChat({ nodes: [{ ...running, outcome: { diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts similarity index 97% rename from packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts rename to packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 262d2e73e1..8b3b0f5eea 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest' import type { - ChatConversationViewNode, ChatSnapshot, ConversationEventInput, - ConversationNodeDefinition, ConversationViewDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' + ChatConversationViewNode, ChatSnapshot, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import { + ConversationNodeAssembler, + type ConversationEventInput, + type ConversationNodeDefinition, + type ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' import { commandDefinition } from '../src/client/conversation-nodes/command.ts' @@ -64,7 +68,6 @@ function at( data, ...extra, } as unknown as ConversationEventInput['event'], - view: undefined, } } @@ -118,6 +121,25 @@ function toolResult(callId: string, text: string) { } describe('built-in conversation node Definitions', () => { + it('keeps ordinary command-only history inactive for the Conversation shell', () => { + const value = assembler([ + at(1, 'command/run', { + commandId: 'command-1', + name: 'help', + source: { kind: 'user' }, + }), + at(2, 'command/done', { + commandId: 'command-1', + kind: 'success', + }), + ]) + const current = snapshot(value) + + expect(current.order).toHaveLength(1) + expect(current.nodes.get(current.order[0] ?? '')?.kind).toBe('command') + expect(chatViewDefinition.isActive?.(current)).toBe(false) + }) + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/runtime/tests/conversation.client.spec.ts b/packages/client/ui-chat/tests/conversation.client.spec.ts similarity index 97% rename from packages/client/runtime/tests/conversation.client.spec.ts rename to packages/client/ui-chat/tests/conversation.client.spec.ts index 1237f73ae8..4664b92879 100644 --- a/packages/client/runtime/tests/conversation.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation.client.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client' -import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts' +import { toAssistantBlock, toAssistantBlocks } from '../src/client/contract/snapshot.ts' describe('toAssistantBlock', () => { it('classifies the four block shapes', () => { diff --git a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx similarity index 89% rename from packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx rename to packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index 604f2fc095..da771f8f28 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -1,13 +1,11 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' import { cleanup, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { apply as nodeApply } from '../src/index.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' -import { zh } from '../src/client/locales.ts' +import { zh } from '../src/client/locale.ts' const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null @@ -15,10 +13,6 @@ const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => afterEach(cleanup) describe('tails', () => { - it('node-half apply tolerates a Host without settings', () => { - expect(() => { nodeApply(new Context()) }).not.toThrow() - }) - it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( { const SID = 's1' as SessionId /** Minimal framework seat for direct DetailsPanel host tests. */ -const SessionProviderStub: SessionProviderComponent = ({ children }) => children(SID) +const SessionProviderStub: SessionProviderComponent = ({ children }) => children /** Observe the owner currency without importing the Tool details renderer. */ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] { @@ -48,15 +53,31 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr } } -function snapshotBase(): ConversationSnapshot { +function sessionSnapshot(): SessionSnapshot { return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, + sessionId: SID, + queue: [], + running: false, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + subagent: null, + lastAgentError: null, + promptAttempted: true, + awaitingFirstTurn: false, } } +function emptyWorkspaces() { + return createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + }) +} + describe('render branch tails', () => { it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => { const view = render( @@ -81,18 +102,12 @@ describe('render branch tails', () => { { kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } }, { kind: 'assistant', seq: 3, time: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } }, ] as const - const snap = { - ...snapshotBase(), - chat: chatSnapshotFixture({ nodes }), - nodes: [ - ...nodes, - ], - } + const snap = chatSnapshotFixture({ nodes }) const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( } + useChat={bindSnapshotSelector(source)} useProjection={() => undefined} />, ) @@ -113,23 +128,27 @@ describe('render branch tails', () => { it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => { localStorage.clear() - const snap = snapshotBase() + const session = sessionSnapshot() + const chatSnapshot = chatSnapshotFixture() const chat = createChatStore().create() chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget) const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) - const emptyWorkspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const workspaces = emptyWorkspaces() const view = render( snap, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(createSnapshotStore(session))} + useChat={bindSnapshotSelector(createSnapshotStore(chatSnapshot))} + useConversation={bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT))} + useTrajectory={(() => { throw new Error('unused') })} useSessions={bindSnapshotSelector(emptyList)} - useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} + useSessionPendingInteraction={bindSnapshotSelector( + createSnapshotStore(new Map()), + )} + useWorkspaces={bindSnapshotSelector(workspaces)} useProjection={(() => undefined)} useInput={(() => { throw new Error('unused') })} inputActions={{ @@ -151,9 +170,9 @@ describe('render branch tails', () => { it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => { localStorage.clear() - const snap = snapshotBase() + const session = sessionSnapshot() const longText = 'x'.repeat(1_000) - snap.runningCalls = [{ + const runningCalls: readonly RunningToolCall[] = [{ callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1, time: 7_000, callView: null, subCalls: [{ kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1', @@ -169,24 +188,27 @@ describe('render branch tails', () => { }], }], }] - snap.chat = chatSnapshotFixture({ runningCalls: snap.runningCalls }) + const chatSnapshot = chatSnapshotFixture({ runningCalls }) const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) - const emptyWorkspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const workspaces = emptyWorkspaces() const owners: DetailsToolOwnerProps[] = [] const view = render( snap, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(createSnapshotStore(session))} + useChat={bindSnapshotSelector(createSnapshotStore(chatSnapshot))} + useConversation={bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT))} + useTrajectory={(() => { throw new Error('unused') })} useSessions={bindSnapshotSelector(emptyList)} - useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} + useSessionPendingInteraction={bindSnapshotSelector( + createSnapshotStore(new Map()), + )} + useWorkspaces={bindSnapshotSelector(workspaces)} useProjection={(() => undefined)} useInput={(() => { throw new Error('unused') })} inputActions={{ @@ -202,7 +224,7 @@ describe('render branch tails', () => { t={t} />, ) - // Conversation resolves the selected sub-call and hands its complete + // Chat resolves the selected sub-call and hands its complete // frozen block to the Tool-owned details seat. expect(view.getByText('read')).toBeTruthy() expect(view.getByTestId('tool-details-seat')).toBeTruthy() diff --git a/packages/client/ui-chat/tests/historical-images.client.spec.ts b/packages/client/ui-chat/tests/historical-images.client.spec.ts new file mode 100644 index 0000000000..1642923a31 --- /dev/null +++ b/packages/client/ui-chat/tests/historical-images.client.spec.ts @@ -0,0 +1,28 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { HistoricalImageCache } from '../src/client/historical-images.ts' + +describe('HistoricalImageCache', () => { + it('invalidates a pending image load when its Session binding is released', async () => { + const read = Promise.withResolvers>>() + const runtime = await SlotTestRuntime.create() + const sessionId = await runtime.sessions.add({ + id: 's1', + session: { readAttachment: () => read.promise }, + }) + const cache = new HistoricalImageCache(runtime.ctx) + const attachment = { + attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + + const pending = cache.resolve(sessionId, attachment) + await runtime.sessions.remove(sessionId) + read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) + + await expect(pending).rejects.toThrow('ui-chat image scope was released before loading completed') + await runtime.dispose() + }) +}) diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx similarity index 55% rename from packages/client/ui-conversation/tests/image-labels.client.spec.tsx rename to packages/client/ui-chat/tests/image-labels.client.spec.tsx index 7f4a986286..b260850bc3 100644 --- a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -7,13 +7,11 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import type { RenderMessageImages } from '../src/client/contract/slots.ts' -import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts' -import { en, zh } from '../src/client/locales.ts' +import { zh } from '../src/client/locale.ts' afterEach(cleanup) const t = makeTranslate(zh, commonZh) -const enT = makeTranslate(en, commonZh) const attachment = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), @@ -39,43 +37,6 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { } } -describe('attachment rejection copy', () => { - const limits = { - maxImageBytes: 5 * 1024 * 1024, - maxImagesPerMessage: 20, - maxMessageImageBytes: 100 * 1024 * 1024, - maxImagePixels: 40_000_000, - maxImageDimension: 2000, - mediaTypes: ['image/png'] as const, - } - - it('renders megabytes without a trailing fraction unless one exists', () => { - expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB') - expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB') - }) - - it('maps user-solvable reasons to limit-naming copy', () => { - expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型') - expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片') - expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试') - expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') - expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') - expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片') - expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB') - expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片') - expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE', limits)).toBe('图片宽高不能超过 2000px,请缩小后重试') - expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images') - }) - - it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => { - expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试') - expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试') - expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试') - expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试') - expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE')).toBe('图片发送失败(IMAGE_DIMENSION_TOO_LARGE),请重新添加图片后再试') - }) -}) - describe('assistant image slot handoff', () => { it('passes one image group and its message alignment to the renderer', () => { const calls: MessageImagesRenderOwner[] = [] diff --git a/packages/client/runtime/tests/partial.client.spec.ts b/packages/client/ui-chat/tests/partial.client.spec.ts similarity index 98% rename from packages/client/runtime/tests/partial.client.spec.ts rename to packages/client/ui-chat/tests/partial.client.spec.ts index 328bd91ee4..dc4f1e2cef 100644 --- a/packages/client/runtime/tests/partial.client.spec.ts +++ b/packages/client/ui-chat/tests/partial.client.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-api-remotes/client' -import { PartialAccumulator } from '../src/client/sessions/partial.ts' +import { PartialAccumulator } from '../src/client/conversation-nodes/partial.ts' const chunk = (c: Record): StreamChunk => c as unknown as StreamChunk diff --git a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx similarity index 98% rename from packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx rename to packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 551a286a88..95954a5060 100644 --- a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { zh } from '../src/client/locale.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' -import { zh } from '../src/client/locales.ts' let nextAnimationFrameId = 1 let animationFrames = new Map() diff --git a/packages/client/ui-chat/tests/selection-survival.client.spec.tsx b/packages/client/ui-chat/tests/selection-survival.client.spec.tsx new file mode 100644 index 0000000000..a9f37c54bf --- /dev/null +++ b/packages/client/ui-chat/tests/selection-survival.client.spec.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +/** Exercises Chat selection through the real SlotRegistry store axis. */ +import { describe, expect, it } from 'vitest' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { createChatStore } from '../src/client/stores.ts' + +const sid = (value: string): SessionId => value as SessionId + +type ChatInstance = ReturnType['create']> + +async function createBench() { + const runtime = await SlotTestRuntime.create() + const chat = createChatStore() + await runtime.root.declare({ + 'conversation.view': { kind: 'list', scope: 'session' }, + 'details': { kind: 'single', scope: 'session' }, + }, (_props: PropsRenderSlots<'conversation.view' | 'details'>) => null) + runtime.slots.register({ name: 'conversation.view', id: 'chat', store: chat }, () => null) + runtime.slots.register({ name: 'details', store: chat }, () => null) + runtime.renderRoot() + return { runtime } +} + +function storeFor( + current: Awaited>, + slot: 'conversation.view' | 'details', + sessionId: SessionId, +): ChatInstance { + return current.runtime.storeOf(slot, sessionId) as ChatInstance +} + +describe('Chat selection survives on its store seat', () => { + it('shares one instance between the Chat View and details panel', async () => { + const b = await createBench() + await b.runtime.sessions.add({ id: 's1' }) + const chat = storeFor(b, 'conversation.view', sid('s1')) + const details = storeFor(b, 'details', sid('s1')) + chat.actions.select({ turnSeq: 3, callId: 'c1' }) + + expect(details).toBe(chat) + expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) + await b.runtime.dispose() + }) + + it('isolates Session instances and preserves identity across list projection updates', async () => { + const b = await createBench() + const oneId = sid('s1') + await b.runtime.sessions.add({ id: 's1' }) + await b.runtime.sessions.add({ id: 's2' }) + const one = storeFor(b, 'conversation.view', oneId) + const two = storeFor(b, 'conversation.view', sid('s2')) + one.actions.select({ turnSeq: 1, callId: 'a' }) + two.actions.select({ turnSeq: 9, callId: 'z' }) + + await b.runtime.sessions.updateSummary(oneId, { displayTitle: 'projected' }) + + expect(storeFor(b, 'conversation.view', oneId)).toBe(one) + expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' }) + expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) + await b.runtime.dispose() + }) + + it('buries selection with the Session scope', async () => { + const b = await createBench() + await b.runtime.sessions.add({ id: 's1' }) + const doomed = storeFor(b, 'conversation.view', sid('s1')) + doomed.actions.select({ turnSeq: 1 }) + + await b.runtime.sessions.remove('s1') + + await b.runtime.sessions.add({ id: 's1' }) + const reborn = storeFor(b, 'conversation.view', sid('s1')) + expect(reborn).not.toBe(doomed) + expect(reborn.store.getSnapshot()).toEqual({ selection: null }) + await b.runtime.dispose() + }) +}) diff --git a/packages/client/runtime/tests/tool-call-tree.client.spec.ts b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts similarity index 97% rename from packages/client/runtime/tests/tool-call-tree.client.spec.ts rename to packages/client/ui-chat/tests/tool-call-tree.client.spec.ts index 541e6b2e19..28cfda51be 100644 --- a/packages/client/runtime/tests/tool-call-tree.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts @@ -1,9 +1,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { describe, expect, it } from 'vitest' -import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts' +import type { RunningToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts' import { MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree, -} from '../src/client/sessions/tool-call-tree.ts' +} from '../src/client/model/tool-call-tree.ts' const at = (seq: number, type: string, data: Record): SessionEvent => ({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent diff --git a/packages/client/ui-conversation/tests/turn-metrics.client.spec.ts b/packages/client/ui-chat/tests/turn-metrics.client.spec.ts similarity index 97% rename from packages/client/ui-conversation/tests/turn-metrics.client.spec.ts rename to packages/client/ui-chat/tests/turn-metrics.client.spec.ts index 0e8d0546ac..19a844c6c4 100644 --- a/packages/client/ui-conversation/tests/turn-metrics.client.spec.ts +++ b/packages/client/ui-chat/tests/turn-metrics.client.spec.ts @@ -1,8 +1,10 @@ // Per-turn latency/throughput fold and the footer figure formatters. import { describe, expect, it } from 'vitest' -import type { AssistantMessageNode, ConversationNode, UserMessageNode } from '@deepseek-ai/dsh-client-runtime/client' -import { assistantStepReading, deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts' +import type { + AssistantMessageNode, ConversationNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import { assistantStepReading, deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts' import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts' interface StepSpec { diff --git a/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx new file mode 100644 index 0000000000..231c1520da --- /dev/null +++ b/packages/client/ui-chat/tests/views-type-chain.client.spec.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import type { ReactNode } from 'react' +import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatViewSlotProps } from '../src/client/contract/slots.ts' + +describe('Chat View type chain', () => { + it('keeps Chat injection and store props out of the target-neutral base', () => { + const negatives = ( + base: ConvViewProps, + chat: ChatViewSlotProps, + ): ReactNode => { + // @ts-expect-error openDetails belongs to the Chat inject face. + void base.openDetails + // @ts-expect-error openDetails accepts a SelectionTarget. + chat.openDetails('nope') + // @ts-expect-error openFile accepts a path. + void chat.openFile({ turnSeq: 1, callId: 'c' }) + return null + } + expect(negatives).toBeTypeOf('function') + }) +}) diff --git a/packages/client/runtime/tsconfig.json b/packages/client/ui-chat/tsconfig.json similarity index 57% rename from packages/client/runtime/tsconfig.json rename to packages/client/ui-chat/tsconfig.json index b5fa3a55e1..c551d4cfc1 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -8,38 +8,35 @@ "src" ], "references": [ - { - "path": "../../attachment/attachment" - }, { "path": "../../../vendor/cordis" }, { - "path": "../ui-slots" + "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../../host/apiproxy" + "path": "../../api/session-controller/tsconfig.client.json" }, { - "path": "../../interaction/commands" + "path": "../../api/workspace-controller/tsconfig.client.json" }, { - "path": "../../core/agent" - }, - { - "path": "../../core/tools" + "path": "../../attachment/attachment" }, { "path": "../../compaction/compaction" }, { - "path": "../../session/session-projection" + "path": "../../core/agent" }, { - "path": "../../session/session-title" + "path": "../../core/session" }, { - "path": "../../todo/tool-todo" + "path": "../../core/tools" + }, + { + "path": "../../interaction/commands" }, { "path": "../../llm/llm" @@ -47,20 +44,44 @@ { "path": "../../llm/llm-retry" }, + { + "path": "../../llm/token-meter" + }, { "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../typert/protocol" + "path": "../../session/session-stats" }, { - "path": "../../typert/registry" + "path": "../locale" }, { - "path": "../../api/remotes/tsconfig.client.json" + "path": "../store" + }, + { + "path": "../ui-approval" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-layout" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-workspace" } - ], - "exclude": [ - "**/*.legacy.*" ] } diff --git a/packages/client/runtime/tsdown.config.ts b/packages/client/ui-chat/tsdown.config.ts similarity index 61% rename from packages/client/runtime/tsdown.config.ts rename to packages/client/ui-chat/tsdown.config.ts index 11118f7f4e..dd950f315d 100644 --- a/packages/client/runtime/tsdown.config.ts +++ b/packages/client/ui-chat/tsdown.config.ts @@ -1,3 +1,3 @@ import { clientBundle } from '../tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-client-runtime', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle('@deepseek-ai/dsh-client-ui-chat', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 649bb29d57..07c5c18a52 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -33,7 +33,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-remotes", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger", "@deepseek-ai/dsh-client-ui-conversation" @@ -52,18 +51,20 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -73,7 +74,12 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index 7903fb44e1..4f4374735b 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -3,7 +3,7 @@ * CommandUiRuntime (`ctx.commandUi`) implements this face; business packages * consume `register` alone. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client' /** Copy for an option that must be acknowledged before onSelect can run. */ diff --git a/packages/client/ui-commands/src/client/directory.ts b/packages/client/ui-commands/src/client/directory.ts index 3e6a9cf2eb..04d5eebb3b 100644 --- a/packages/client/ui-commands/src/client/directory.ts +++ b/packages/client/ui-commands/src/client/directory.ts @@ -6,7 +6,7 @@ * is the only extra dimension. */ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index bc8e2ca7fc..26e8a32e09 100644 --- a/packages/client/ui-commands/src/client/index.ts +++ b/packages/client/ui-commands/src/client/index.ts @@ -5,13 +5,16 @@ * popupSelect shell self-registers into conversation.input.overlay with * per-session resolution. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the 'conversation.input.overlay' SlotMap declaration (the // key's owner) into this program so the overlay registration below typechecks // against the real declaration — no runtime edge to ui-conversation. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { CommandUiRuntime } from './service.ts' import type { PopupSelectInjected } from './PopupSelectView.tsx' import { PopupSelectView } from './PopupSelectView.tsx' @@ -57,7 +60,7 @@ export function apply(ctx: ClientContext): void { ctx.plugin(CommandUiRuntime) ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => { const command = scope.commandUi - const sessions = scope.sessions + const sessions = scope.get('sessions') as ISessions scope.slots.inject('conversation.input.overlay', () => scope.slots.register({ name: 'conversation.input.overlay', id: 'command-popup', diff --git a/packages/client/ui-commands/src/client/popup.ts b/packages/client/ui-commands/src/client/popup.ts index e9d2e05a6b..f7e7b186be 100644 --- a/packages/client/ui-commands/src/client/popup.ts +++ b/packages/client/ui-commands/src/client/popup.ts @@ -9,8 +9,8 @@ * Input side owns the span/bare-token CAS guard) and focuses the composer; * the controller never touches the input machine. */ -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { SelectOption } from './contract.ts' diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 5db2033e29..3ae9b5d324 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -13,7 +13,9 @@ import type { Context } from '@deepseek-ai/cordis' // (`commands/change` rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' -import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, diff --git a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts index 3b243b25f2..c2143a35c3 100644 --- a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts @@ -8,8 +8,9 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandUiContract } from '../src/client/contract.ts' import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx' diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 1d3bc796f4..ffd2946b80 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -10,8 +10,9 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' -import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' @@ -112,19 +113,7 @@ async function bench(opts: BenchOptions = {}) { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) - const forwarded = new Map void>>() - ctx.provide('remote', { - commands: commandsRemote, - $on: (event: string, listener: (...args: never[]) => void) => { - const listeners = forwarded.get(event) ?? [] - listeners.push(listener) - forwarded.set(event, listeners) - return () => { forwarded.set(event, listeners.filter(entry => entry !== listener)) } - }, - $dispatch: (event: string, args: readonly unknown[]) => { - for (const listener of forwarded.get(event) ?? []) listener(...args as never[]) - }, - }) + const remote = Object.assign(new TestRemote(ctx), { commands: commandsRemote }) ctx.provide('remote.commands', commandsRemote) const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = [] ctx.on('command/executed', (sessionId, name, result) => { @@ -155,7 +144,7 @@ async function bench(opts: BenchOptions = {}) { const warm = async (session: ClientSessionContext) => { await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal }) } - return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices } + return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, remote } } function menuPick(source: InputTriggerSource, name: string, session: ClientSessionContext, end?: number) { @@ -760,7 +749,7 @@ describe('popupFor', () => { describe('directory invalidation events', () => { it('commands/change repulls in the background while the old snapshot serves', async () => { let round = 0 - const { ctx, source, warm } = await bench({ + const { source, warm, remote } = await bench({ commands: () => { round += 1 return Promise.resolve({ @@ -771,7 +760,7 @@ describe('directory invalidation events', () => { }, }) await warm(proj('s1')) - ctx.remote.$dispatch('commands/change', []) + remote.emit('commands/change', []) await new Promise(resolve => setTimeout(resolve, 0)) expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() @@ -779,7 +768,7 @@ describe('directory invalidation events', () => { it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => { const rounds = new Map() - const { ctx, source, warm } = await bench({ + const { source, warm, remote } = await bench({ commands: (payload) => { const round = (rounds.get(payload.sessionId) ?? 0) + 1 rounds.set(payload.sessionId, round) @@ -794,7 +783,7 @@ describe('directory invalidation events', () => { await warm(proj('s2')) // A preset switch changes which commands one session's agent resolves; // every other session keeps the catalog its own composition serves. - ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal']) + remote.emit('agent-preset/selected', [sid('s1'), 'minimal']) await new Promise(resolve => setTimeout(resolve, 0)) expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() diff --git a/packages/client/ui-commands/tsconfig.json b/packages/client/ui-commands/tsconfig.json index 93b067ff95..f4c9d3b26a 100644 --- a/packages/client/ui-commands/tsconfig.json +++ b/packages/client/ui-commands/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -18,7 +21,7 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../ui-conversation" @@ -26,6 +29,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-input-trigger" }, @@ -36,10 +45,10 @@ "path": "../../interaction/commands" }, { - "path": "../../runtime-diagnostics/invariants" + "path": "../../core/session" }, { - "path": "../../api/remotes/tsconfig.client.json" + "path": "../../runtime-diagnostics/invariants" } ] } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2b24d725a5..ffb66bc995 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 96f67677f690f12929cf691ab5901ded399504d3 -README.zh.md: 05fdcd7a989fb74eb9f86f11cffb4eb77d38088a +README.md: 6f47cd87af46cc270d3160482ad047b249aa5053 +README.zh.md: 38e3c626073e9e90a16eebdb91af1e89d4da7c22 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 96f67677f6..6f47cd87af 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,66 +2,79 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, and turn status), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), details shell, and scope-addressed ConversationController. Tool presentation belongs to [`ui-tool`](../ui-tool/README.md). +`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compaction/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compaction/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable. +## Conversation assembly -The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. The Hero's leading mark is the independent root-scoped `conversation.hero.brand.mark` slot, with the fish mark as its fallback. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows the current session title, optional lineage controls, and view tabs as ordinary column chrome; ordinary fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)). +`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. -Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model-selection, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. +The adapter converts each `SessionEventEntry` to `ConversationEventInput` as `{ event, view? }`: the raw Session event is preserved and the envelope-level tool view is included only when present. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. -The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. +Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. -Chat business rows are independent registry contributions rather than a closed built-in union. A client plugin declaration-merges its typed `ChatNodeDataMap` key, registers a `ConversationNodeDefinition` on `ctx.conversationEvents`, and registers the matching keyed renderer on `conversation.chat.node`; it does not modify Session folds or a central renderer switch. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) covers stable event ids, append/prepend replay, Location data, and renderer constraints. +## Shell and standard props -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-user-questions) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +The package registers the optional-Session `conversation` shell, strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. -The session header dispatches each current ordinary title and subagent breadcrumb through the optional session-scoped `'conversation.session.header.lineage'` seat, followed by the `'conversation.session.header.actions'` list and the independent `'conversation.session.header.utilities'` list at the right edge. Each lineage owner supplies plain breadcrumb identity and display text; the render site retains the ordinary title as fallback, and an ancestor also supplies its upward-navigation callback. Removing the occupant restores every title without affecting header actions, and optional Session utilities cannot reorder or move either group. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. +View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. The Chat snapshot associates exact labels only from that immediately following sourced recall, preserving multi-word titles without carrying one recall's labels onto a later direct message. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Like claimed slash commands, composer references keep their complete display text in the transparent textarea and use the aligned backdrop for color and the leading domain glyph; native text metrics own width, wrapping, selection, and caret placement. The occurrence range remains structured for serialization and boundary deletion, while an edit inside it converts the remaining characters to ordinary text. The session draft mirror stores each occurrence's clipboard projection, so a remount without the occurrence table restores canonical parseable reference text instead of a display-only label. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same textarea mounted but inert while the Workspace picker connects a blank Session. Draft text is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. -A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). +## Temporary composer entries -The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. A path click through the injected `openFile` asks the Host to open that path (relative paths resolve against the session cwd). A Host or OS refusal opens an in-page dialog with the thrown reason and a Retry of the same path; Cancel, Escape, the close control, and a mask click dismiss it ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md)). +`conversation.composer` is a generic chain. Its complete owner currency is: -The chat flow projects each producer-correlated retry chain into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed attempt's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. A terminal failure renders as a persistent inline status at its turn boundary — beside the settled retry row when retries exhausted — showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. +```ts type-equiv +/** Owner values used to elect a composer takeover. */ +interface ComposerChainProps { + /** Current Session identity used by temporary business-owned entries. */ + sessionId: SessionId | undefined + /** Current Session lifecycle state, absent without a selected Session. */ + session: SessionSnapshot | undefined + /** Effective business-owned interaction awaiting the user in this Session. */ + pendingInteraction: SessionPendingInteraction | undefined +} +``` -`TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted). The dock adapter owns selection so the panel stays a pure function of its props. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-user-questions's) hides the whole dock, this strip included. The `todo_write` Tool row belongs to [`ui-tool`](../ui-tool/README.md). +A business package may install one entry only while a Remote waterfall request is pending: -`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. +```tsx +import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChainSelect, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority. +interface Request { + readonly sessionId: SessionId +} -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the Host-backed `ui-conversation.busyEnter` General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; the local settings provider stores it in `$DSH_HOME/settings.yaml`, so the choice follows the same user home across Web ports. Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +type RequestComposerProps = + PropsRuntime<'conversation.composer'> & { matched: Request } -Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. +const select: ChainSelect = owner => + owner.sessionId === request.sessionId ? request : null -Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code. Attached images are part of the submission envelope on every send path: a slash-command submit either consumes them (a claim declaring `images` has them serialized through the hub's `commandImages` plumbing, passed to `claim.submit`, and cleared plus released only on a success outcome) or refuses the whole submission with the `command.imagesUnsupported` notice while draft and images stay in place — a command can never consume the text and strand the images. +const dispose = ctx.slots.register( + { name: 'conversation.composer', select }, + RequestComposer, +) -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `InputTriggerController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-input-trigger's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. +try { + return await request.result +} finally { + dispose() +} +``` -The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Every non-empty ratio starts with integer rounding. A non-full ratio adds decimal places only while the current precision would round to 100%, stopping at the minimum precision that remains below 100%; only a full cache hit displays 100%, and the precision has no fixed limit. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. - -`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations. - -A finished turn materializes one ordered `turn-tail` Conversation Node. Its engine-owned `TurnLocation` supplies the closing Assistant and Turn data; the renderer places the `conversation.chat.turnTail` chain before that node's IconActions and dispatches `TurnTailOwnerProps` containing the Turn, closing seq, and `openFile`. This package owns only the hole; `@deepseek-ai/dsh-client-ui-deliverables` accumulates mutation-tool `locations` into Turn data and owns the produced-files row, chip cap, and copy, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert. +The selector must be a pure function of the owner currency. Its non-null return is delivered to the component as `matched`; `PropsRuntime<'conversation.composer'>` supplies the standard Session and global props. Chain order remains ascending `priority`, then registration order, and the first non-null selector wins. The shell keeps the default composer mounted beneath a takeover. Request state, listeners, response encoding, and any request-specific child slots belong to the business package; they are not carried by `SessionSnapshot` or declared by this core package. ## Model Experience -None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request. +None, as this package renders browser state and sends user-admitted inputs through Session Controller APIs without constructing model requests. #### KV Cache effect -None; this package neither assembles nor sends a provider request. +None; Conversation assembly and browser input state do not alter provider-side prompt caching. ## Known Limitations and Deferred Work -- **The stats-line fallback fold covers the in-window flow only** — without the `sessionStats` projection (an assembly that does not mount the unit), every figure folds the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted and the numbers grow per loaded page. -- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). -- **Sent user messages cannot be edited** — user bubbles retain clock and copy; branch lives only under assistant answers ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)). Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). -- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. -- **The approval panel has no durable grant control** — it supports allow-once and reject only. -- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. -- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels. -- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `user/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority. +- **Only registered targets can render** — the shell deliberately has no implicit fallback target beyond the registered `chat` preference. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 05fdcd7a98..38e3c62607 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,66 +2,79 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离与轮次状态)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、详情壳层,以及按 scope 寻址的 ConversationController。工具展示属于 [`ui-tool`](../ui-tool/README.zh.md)。 +`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compaction/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩(context compaction)图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;被引用的 `compaction/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。 +## Conversation 组装 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。Hero 前方的标记是独立的根作用域 `conversation.hero.brand.mark` slot,未被占用时回退到鱼形标记。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,显示当前会话 title、可选谱系控件和视图标签;普通 fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md))。 +`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 event source。 -别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model-selection,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它无法路由的提示词。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 +adapter 将每个 `SessionEventEntry` 转换成 `{ event, view? }` 形式的 `ConversationEventInput`:原始 Session event 保持不变,仅在 envelope-level tool view 存在时携带 `view`。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 -视图环是一个 slot:严格会话主体注册在 `children` 表中声明会话作用域的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 +target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。 -Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。Client 插件通过 declaration merging 增加类型化 `ChatNodeDataMap` key,在 `ctx.conversationEvents` 上注册 `ConversationNodeDefinition`,再向 `conversation.chat.node` 注册匹配的 keyed renderer;它无须修改会话 fold 或中央 renderer switch。稳定事件 id、append/prepend 回放、Location data 与 renderer 约束见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.zh.md)。 +## Shell 与标准 props -会话页头通过可选的会话作用域 `'conversation.session.header.lineage'` seat 派发当前普通 title 与每一级 subagent 面包屑,随后依次渲染 `'conversation.session.header.actions'` 列表和最右侧独立的 `'conversation.session.header.utilities'` 列表。每个谱系 owner 都会提供纯数据形式的面包屑身份与显示文本;render site 保留普通 title 作为回退,祖先还会提供向上导航的回调。移除 occupant 会恢复每个 title,且不影响页头操作;可选的会话工具不会改变这两个区域的顺序或位置。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 +本包注册 optional-Session `conversation` shell、strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。Chat 快照只从紧随其后的带来源召回中关联准确标签,因此既能保留多词标题,也不会把一条召回的标签带到后续直接消息上。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。occurrence 范围仍为序列化与边界整段删除保留结构身份,在范围内部编辑则会把剩余字符转为普通文本。会话草稿镜像会存储每个 occurrence 的剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,会恢复可解析的规范引用文本,而不是仅供显示的标签。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 +View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 -Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md))。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个 textarea 保持 inert,Workspace picker 连接 blank Session;草稿文本镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 -聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.zh.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。经注入的 `openFile` 点击路径会请 Host 打开该路径(相对路径按会话 cwd 解析)。Host 或操作系统拒绝时,页面内对话框展示抛出的原因,并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框([决策](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。 +## 临时 composer entry -聊天流会把每条生产方关联的重试链投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每次失败尝试的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。终态失败会在其轮次边界渲染为持久的内联状态——重试耗尽后与定格的重试行并列——展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 +`conversation.composer` 是通用 chain,其完整 owner currency 为: -审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +```ts type-equiv +/** Owner values used to elect a composer takeover. */ +interface ComposerChainProps { + /** Current Session identity used by temporary business-owned entries. */ + sessionId: SessionId | undefined + /** Current Session lifecycle state, absent without a selected Session. */ + session: SessionSnapshot | undefined + /** Effective business-owned interaction awaiting the user in this Session. */ + pendingInteraction: SessionPendingInteraction | undefined +} +``` -`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(当前计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` 工具行属于 [`ui-tool`](../ui-tool/README.zh.md)。 +业务 package 可仅在一个 Remote waterfall request pending 期间安装 entry: -`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 +```tsx +import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChainSelect, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md))——并能在重连后从同一权威恢复。 +interface Request { + readonly sessionId: SessionId +} -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,由 Host settings 支撑的 `ui-conversation.busyEnter` General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;本地 settings 提供方将其存入 `$DSH_HOME/settings.yaml`,因此该选择会跟随同一个用户 home 跨越 Web 端口。Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约定:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md)拥有。 +type RequestComposerProps = + PropsRuntime<'conversation.composer'> & { matched: Request } -逐会话 UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 +const select: ChainSelect = owner => + owner.sessionId === request.sessionId ? request : null -图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。已附加的图片在每条发送路径上都是提交信封的一部分:斜杠命令提交要么消费它们(声明 `images` 的 claim 经 hub 的 `commandImages` 管道序列化图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放),要么以 `command.imagesUnsupported` 通知拒绝整个提交,草稿与图片原样保留——命令不可能消费了文字却把图片留在原地。 +const dispose = ctx.slots.register( + { name: 'conversation.composer', select }, + RequestComposer, +) -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `InputTriggerController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-input-trigger 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +try { + return await request.result +} finally { + dispose() +} +``` -聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。所有非空比率都先按整数舍入。非满命中只有在当前精度会舍入成 100% 时才增加小数位,并在首次得到低于 100% 的结果时停止;只有完整缓存命中才显示 100%,且精度没有固定上限。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.zh.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 - -`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。 - -完成的一轮会物化一个有序的 `turn-tail` Conversation Node。它由引擎维护的 `TurnLocation` 提供收尾 Assistant 和 Turn data;renderer 在该 Node 的 IconActions 之前渲染 `conversation.chat.turnTail` chain,并派发包含 Turn、收尾 seq 和 `openFile` 的 `TurnTailOwnerProps`。本包只拥有空位;`@deepseek-ai/dsh-client-ui-deliverables` 把改写工具的 `locations` 累积到 Turn data,并拥有产物行、chip 上限和文案,因此把该插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。 +selector 必须是 owner currency 的纯函数。非 null 返回值作为 `matched` 传给组件;`PropsRuntime<'conversation.composer'>` 提供标准 Session 与 global props。Chain 顺序仍按 `priority` 升序,再按注册顺序;首个返回非 null 的 selector 获选。Shell 会在 takeover 下保持默认 composer 挂载。Request 状态、listener、response encoding 和任何 request-specific child slot 都属于业务 package,不进入 `SessionSnapshot`,也不由 core package 声明。 ## 模型体验 -无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。 +无,因为本包渲染浏览器状态,并通过 Session Controller API 发送用户确认提交的输入,而不构造模型请求。 #### KV Cache 影响 -无;该包既不组装也不发送提供方请求。 +无;Conversation 组装和浏览器输入状态不会改变提供方侧的 prompt cache。 ## 已知限制与暂缓事项 -- **统计行的回退折算只覆盖窗口内消息流**:未组合 `sessionStats` 投影单元的装配中,所有数字由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入,数字随加载页数增长。 -- **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md))。 -- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;分支只存在于 assistant 回答之下([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md))。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md))。 -- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 -- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 -- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 -- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering 操作会被保存和取消取代;Enter 保存,Escape 取消。 -- **Queue 严格 steering 会保留完整消息**:agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering,直到已消费的 `user/message` 折叠进持久 transcript(文本记录),因此立即展示、重连和回放共享同一个线性权威。 +- **只有已注册 target 可以渲染**——除已注册的 `chat` 偏好外,shell 刻意不提供隐式 fallback target。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index f06804790b..876a3dbd9d 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", - "description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host", + "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -31,13 +31,17 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ - "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-api-remotes", - "@deepseek-ai/dsh-client-ui-layout" + "@deepseek-ai/dsh-client-ui-workspace" ], "platform": "web" } @@ -48,64 +52,66 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^" + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7920104d68..b154e73f4a 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,66 +1,56 @@ -/** Registers the conversation components, shared store, and service callbacks. */ +/** Registers the target-neutral Conversation assembly, shell, input, and docks. */ import type { Context } from '@deepseek-ai/cordis' -import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import { - resolveWorkspacePath, type ISessions, type SessionId, -} from '@deepseek-ai/dsh-client-runtime/client' -// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration -// goes through the service, never a value import (client bundle purity gate). -import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +// Type-only service and declaration merges used by this assembly. import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { UiConversation } from './conversation/assembly.ts' import type { ViewTab } from './contract/views.ts' import type { - ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, - ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, - DetailsInjected, + ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, + ConversationSessionInjected, } from './contract/slots.ts' -import type { InputNotice } from './input/contract.ts' -import { createChatStore } from './stores.ts' +import type { InputNotice } from './contract/input.ts' +import { createConversationStore } from './stores.ts' import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' -import type { ComposerBlock } from './input/blocks.ts' +import type { ComposerBlock } from './contract/composer-blocks.ts' import { InputHub } from './input/hub.ts' import { ComposerSubmissionPolicy } from './input/submission-policy.ts' -import { InputBar } from './skeleton/InputBar.tsx' +import { queueDockEntry } from './queue/QueueDock.tsx' import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx' import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx' -import { ChatView } from './chat/ChatView.tsx' -import { StatsLine } from './chat/StatsLine.tsx' -import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' -import { todoDockEntry } from './skeleton/TodoPanel.tsx' -import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' -import { DetailsPanel } from './skeleton/DetailsPanel.tsx' +import { InputBar } from './skeleton/InputBar.tsx' +import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' -import { registerConversationNodes } from './conversation-nodes/register.ts' -import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** The conversation skeleton, chat flow, commands, details, and docks copy. */ + /** Conversation shell, composer, queue, and dock copy. */ conversation: ConversationKey } } -/** Services required by the conversation plugin. */ +/** Services required by the Conversation plugin. */ export const inject = [ - 'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'settingsScope', - 'conversationEvents', 'conversationViews', + 'slots', 'sessions', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope', ] -// Static no-session sources for the composer-bar hooks compartment: module -// constants so the render side's per-source hook cache (observableHook) keeps -// one identity across every no-session render. +// Stable no-session sources keep the renderer's observable-hook cache and +// hook order unchanged across current-Session transitions. const ABSENT_NOTICES = { getSnapshot: (): InputNotice | null => null, subscribe: () => () => {}, } -/** No session, therefore nothing to block; same one-identity rule as above. */ const ABSENT_BLOCK = { getSnapshot: (): ComposerBlock | undefined => undefined, subscribe: () => () => {}, @@ -75,61 +65,43 @@ const ABSENT_MENU_LAUNCHER = { subscribe: () => () => {}, } -const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { - hooks: { - turnData: ({ useSession }, nodeKey) => function useTurnData(key) { - return useSession((snapshot) => { - const location = snapshot.chat.nodes.get(nodeKey)?.location - return location?.kind === 'turn' || location?.kind === 'step' - ? location.turn.data.get(key) - : undefined - }) - }, - }, +interface WorkspaceNavigation { + connectWorkspace( + workspaceId: Parameters[0], + ): Promise } -/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ +/** Resolve the session-scoped Conversation action face, failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { const scoped = sessions.scope(id) if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`) const conversation = scoped.get('conversation') - if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable through the session scope') + if (conversation === undefined) { + throw new Error('ui-conversation: conversation service unavailable through the session scope') + } return conversation } -/** Resolve package-internal attachment operations from the public service registration. */ +/** Resolve package-internal attachment operations from the public service. */ function concreteConversation(ctx: Context): ConversationController { const conversation = ctx.get('conversation') as ConversationController | undefined if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') return conversation } -/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */ -function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null { - return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null -} - -/** Mounts the conversation plugin. +/** + * Mount the Conversation core and target-neutral presentation. * @param ctx - Client root context. */ export function apply(ctx: Context): void { const sessions = ctx.sessions - const workspaces = ctx.workspaces - const layout = ctx.layout const slots = ctx.slots - - registerConversationNodes(ctx) - registerChatNodeRenderers(ctx) + const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation + const uiConversation = new UiConversation(ctx, sessions) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') - - // Registration-time text (the view tab label) reads through the bound - // translate as a thunk, so it follows the active locale without - // re-registration; components read the standard `t` seat instead. const t = ctx.locale.bind(NS) - - // Apply-time construction keeps store identity bound to this fiber. - const chatStore = createChatStore() + const conversationStore = createConversationStore() const submissionPolicy = new ComposerSubmissionPolicy( ctx.settingsScope.bind({ namespace: CONVERSATION_SETTINGS_NAMESPACE }), ) @@ -145,55 +117,59 @@ export function apply(ctx: Context): void { }), }, EnterBehaviorRow)) - // Chat semantic reader positions by session, surviving view switches and - // width reflow when the tab ring remounts the view. Deliberately not - // persisted: a fresh page load keeps the open-jump-to-bottom default. - const chatScrollPositions = new Map() - const viewTabs = (): ViewTab[] => { const tabs: ViewTab[] = [] for (const entry of slots.entries('conversation.view')) { - /* v8 ignore next -- unreachable: list registration validates id at load. */ + /* v8 ignore next -- list registration validates id at load. */ if (entry.options.id === undefined) continue - tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id }) + tabs.push({ + id: entry.options.id, + label: resolveSlotLabel(entry.options.label) ?? entry.options.id, + }) } return tabs } - const views = { - list: viewTabs, - subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), - version: () => slots.getVersion('conversation.view'), + const conversationViews = createSnapshotStore(viewTabs()) + const refreshViews = (): void => { + const current = conversationViews.getSnapshot() + const next = viewTabs() + if (current.length === next.length + && current.every((tab, index) => { + const candidate = next.at(index) + return candidate !== undefined && tab.id === candidate.id && tab.label === candidate.label + })) return + conversationViews.set(next) } + ctx.effect(() => { + const disposeViews = slots.subscribe('conversation.view', refreshViews) + const disposeLocale = ctx.locale.subscribe(refreshViews) + return () => { + disposeLocale() + disposeViews() + } + }, 'ui-conversation: View roster') - // The per-session input machine registry (SessionInputResolver face; published as - // ctx.conversation.input by the service below sharing this one instance). const inputHub = new InputHub(ctx, t) - - // The composer-block registry: a plugin that knows a session cannot send — - // ui-model-selection, when no adapter serves the session's route — raises a block - // here, and the bar reads its own session's store. It cannot flow the other - // way: this package must not import the plugins that would know. const composerBlocks = new ComposerBlockRegistry() - // The input machine feeds every session-scope slot - // component through the standard provide channel — the 'input' hook plus - // the two public actions. Materialization is the shell creation trigger - // (per-session lazy; scope disposer tears down). - ctx.effect(() => sessions.provide({ - hooks: ['input'], + // Conversation assembly and input share the Session binding lifecycle. The + // source roster is installed before any consuming Slot entry. + ctx.uiSession.provide({ + hooks: ['conversation', 'input'], props: ['inputActions'], resolve: (binding) => { const shell = inputHub.shellFor(binding) return { - hooks: { input: shell.state }, + hooks: { + conversation: uiConversation.binding(binding).snapshot, + input: shell.state, + }, props: { inputActions: shell.actions }, } }, - }), 'ui-conversation: input standard-kit provider') + }) - // Resident current-session-optional shell. It owns the stable Hero/composer - // frame while strict session slots fill only their session-bound regions. - slots.register({ + const registerConversationRoot = () => slots.register({ name: 'conversation', locale: NS, children: { @@ -211,9 +187,11 @@ export function apply(ctx: Context): void { 'conversation.hero.agentPreset': { kind: 'single', scope: 'root' }, }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ - hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) }, + hooks: { + composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), + }, selectWorkspace: async (workspaceId) => { - const nextId = await workspaces.connectWorkspace(workspaceId) + const nextId = await workspaceNavigation.connectWorkspace(workspaceId) if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft @@ -234,28 +212,19 @@ export function apply(ctx: Context): void { }), }, ConversationRoot) - // The strict session body fills the resident scrollport without owning it; - // the Hero/composer path therefore stays fixed while the first blank - // session appears after a Workspace pick. - slots.register({ + const registerConversationSession = () => slots.register({ name: 'conversation.session', children: { 'conversation.view': { kind: 'list', scope: 'session' }, }, - store: chatStore, - inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => { - const conversation = concreteConversation(ctx) - return { - views, - releaseSessionImages: (id) => { conversation.releaseSessionImages(id) }, - bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), - } - }, + store: conversationStore, + inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => ({ + hooks: { conversationViews }, + bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), + }), }, ConversationSession) - // Header chrome sits above the resident scrollport but shares the same - // per-session chat store (active view) as its body and view entries. - slots.register({ + const registerConversationHeader = () => slots.register({ name: 'conversation.session.header', locale: NS, children: { @@ -263,26 +232,16 @@ export function apply(ctx: Context): void { 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, 'conversation.session.header.utilities': { kind: 'list', scope: 'session' }, }, - store: chatStore, + store: conversationStore, inject: (): ConversationSessionHeaderInjected => ({ - views, + hooks: { conversationViews }, open: (id) => { sessions.open(id) }, }), }, ConversationSessionHeader) - // The default composer body: its own single slot inside the composer - // chain's fallback. Public machine surface arrives via the - // provide channel above; the keyboard command face and the stop/retry - // verbs ride this inject (package-internal — hub and bar are one plugin). - // Session-maybe: with no current session the machine faces are absent and - // the hooks compartment binds static empty sources (module constants, so - // observableHook caching and hook order stay stable across transitions). - slots.register({ + const registerComposerBar = () => slots.register({ name: 'conversation.composer.bar', locale: NS, - // The two named control seats in the bar's tool row (plan beside the - // access control, model right); empty until their owning plugins - // register. children: { 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, 'conversation.input.plan': { kind: 'single', scope: 'session' }, @@ -300,7 +259,11 @@ export function apply(ctx: Context): void { toggleCommandMenu: undefined, stop: undefined, command: undefined, - hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER }, + hooks: { + notices: ABSENT_NOTICES, + lexicon: ABSENT_LEXICON, + menuLauncher: ABSENT_MENU_LAUNCHER, + }, } } const conversation = concreteConversation(ctx) @@ -316,11 +279,7 @@ export function apply(ctx: Context): void { } return null } catch (error: unknown) { - if (error instanceof UnsupportedImageMediaTypeError) { - // Positive copy: the supported list is fixed in imageMediaType, - // and naming it beats echoing the rejected MIME type back. - return t('image.unsupportedType') - } + if (error instanceof UnsupportedImageMediaTypeError) return t('image.unsupportedType') return error instanceof Error ? error.message : String(error) } }, @@ -346,7 +305,7 @@ export function apply(ctx: Context): void { }, stop: () => { scopedConversation(sessions, sessionId).cancel().catch(() => { - // Stop failure surfaces via snapshot.promptError; nothing to restore. + // Stop failure is published through Session promptError. }) }, command: async (line) => { @@ -364,95 +323,14 @@ export function apply(ctx: Context): void { }, }, InputBar) - // The approval takeover: a selector-routed entry of the chain this package - // just declared (the ui-user-questions registration pattern; the entry lives here - // because approval answering is core conversation UX, not an optional tool). - // Zero business face — data and verbs both ride the matched carrier. - // priority 1: question takeovers (default 0) win when both kinds are - // pending — a question is a conversation the model is waiting on, while an - // approval only blocks one tool call; answering the question first cannot - // strand the approval (it re-elects the moment the question resolves). - slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel) + slots.inject('conversation', function* () { + yield registerConversationRoot() + yield registerConversationSession() + yield registerConversationHeader() + yield registerComposerBar() + }) - // The chat view: first entry of the ring this package just declared. - // ChatView owns only the stable ordered Node list. Business renderers are - // independently keyed behind its one Node seat. - slots.register({ - name: 'conversation.view', - id: 'chat', - order: 0, - label: () => t('view.chat'), - locale: NS, - children: { - 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, - 'conversation.message.images': { kind: 'single', scope: 'session' }, - }, - store: chatStore, - inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { - const conversation = concreteConversation(ctx) - const scoped = scopedConversation(sessions, sessionId) - return { - openDetails: (target) => { - actions.select(target) - layout.openDetails() - }, - fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner), - openFile: (path) => { - const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd - return workspaces.openPath(resolveWorkspacePath(cwd, path)) - }, - loadOlder: () => { void scoped.loadOlder() }, - loadImage: attachment => conversation.resolveImage(sessionId, attachment), - // Unregistered 'trajectory' id is safe: the tab ring falls back to - // the first view, and the untouched inspect target stays inert. - inspectCall: (callId) => { - actions.setInspect({ callId }) - actions.setView('trajectory') - }, - chatScroll: { - save: (position) => { - if (position === null) chatScrollPositions.delete(sessionId) - else chatScrollPositions.set(sessionId, position) - }, - read: () => chatScrollPositions.get(sessionId) ?? null, - }, - forkAt: (seq) => { - sessions.fork({ sessionId, atSeq: seq, increaseTitle: true }) - .then((childId) => { sessions.open(childId) }) - .catch(() => { - // Fork or child-rename failure keeps the source view untouched. - }) - }, - } - }, - }, ChatView) - - // Session stats stick with the composer (composer.dock = stats-line family). - slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS }, StatsLine) - - // Class-plugin mount (packages/AGENTS.md service form): the service - // registers itself as `conversation` and lives on its own child fiber. - // Presentation registrants depend directly on their slot declarations; - // this service remains only where conversation actions are required. ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks }) - - // The plan strip rides the input dock above the queue rows (same posture). ctx.plugin(todoDockEntry) - - // The read-only queue dock entry rides the same - // registration path into the input dock declared above. ctx.plugin(queueDockEntry) - - slots.register({ - name: 'details', - locale: NS, - children: { - 'conversation.details.tool': { kind: 'single', scope: 'session' }, - }, - store: chatStore, - inject: (): DetailsInjected => ({ - closeDetails: () => { layout.closeDetails() }, - }), - }, DetailsPanel) - } diff --git a/packages/client/ui-conversation/src/client/browser-bytes.ts b/packages/client/ui-conversation/src/client/browser-bytes.ts new file mode 100644 index 0000000000..90d881f1c6 --- /dev/null +++ b/packages/client/ui-conversation/src/client/browser-bytes.ts @@ -0,0 +1,13 @@ +/** + * Encode bytes as canonical browser base64 without overflowing argument limits. + * @param data - bytes to encode. + * @returns base64 text. + */ +export function bytesToBase64(data: Uint8Array): string { + let binary = '' + const chunk = 0x8000 + for (let offset = 0; offset < data.length; offset += chunk) { + binary += String.fromCharCode(...data.subarray(offset, offset + chunk)) + } + return btoa(binary) +} diff --git a/packages/client/ui-conversation/src/client/context-occupancy.ts b/packages/client/ui-conversation/src/client/context-occupancy.ts new file mode 100644 index 0000000000..4ee330436f --- /dev/null +++ b/packages/client/ui-conversation/src/client/context-occupancy.ts @@ -0,0 +1,25 @@ +import type { ContextPressureProjection } from '@deepseek-ai/dsh-token-meter/client' + +/** Context usage rendered by conversation and Chat status surfaces. */ +export interface ContextOccupancy { + percent: number + usedTokens: number + contextWindow: number +} + +/** + * Resolve bounded display occupancy from independently updated pressure fields. + * @param pressure - latest token-meter projection. + * @returns occupancy, or null until numerator and capacity are known. + */ +export function contextOccupancy( + pressure: ContextPressureProjection | undefined, +): ContextOccupancy | null { + const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens + if (usedTokens === undefined || pressure?.contextWindow === undefined) return null + return { + percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)), + usedTokens, + contextWindow: pressure.contextWindow, + } +} diff --git a/packages/client/ui-conversation/src/client/contract/composer-blocks.ts b/packages/client/ui-conversation/src/client/contract/composer-blocks.ts new file mode 100644 index 0000000000..bf25624b04 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/composer-blocks.ts @@ -0,0 +1,29 @@ +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Why one session's composer is inert. */ +export interface ComposerBlock { + /** Localized placeholder owned by the plugin that raised the block. */ + readonly reason: string +} + +/** The registry face other plugins reach through `ctx.conversation.blocks`. */ +export interface ComposerBlocks { + /** + * Raise or clear this session's block. + * @param sessionId - Session whose composer is affected. + * @param block - Block to raise, or undefined to clear it. + */ + set(sessionId: SessionId, block: ComposerBlock | undefined): void + /** + * Resolve the observable block state for one Session. + * @param sessionId - Session to observe. + * @returns Identity-stable block store. + */ + storeFor(sessionId: SessionId): SnapshotStore + /** + * Drop one Session's store. + * @param sessionId - Session being released. + */ + forget(sessionId: SessionId): void +} diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/ui-conversation/src/client/contract/context-provenance.ts similarity index 98% rename from packages/client/runtime/src/client/sessions/context-provenance.ts rename to packages/client/ui-conversation/src/client/contract/context-provenance.ts index dbd3b2dd30..8fb154e558 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/ui-conversation/src/client/contract/context-provenance.ts @@ -1,4 +1,4 @@ -// Context source projection: the role and the human-facing producer name +// Conversation context source projection: the role and the human-facing producer name // of one logged non-user `user/message`, read from its durable `source` alone. // The client keeps no table of known plugin ids — a renamed or newly mounted // producer must never need a client release to stay identifiable, and a resumed diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts similarity index 96% rename from packages/client/runtime/src/client/contract/conversation.ts rename to packages/client/ui-conversation/src/client/contract/conversation.ts index f14fb02d7c..01a019802d 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -1,5 +1,5 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types' /* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents -- * The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program; @@ -8,7 +8,7 @@ import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client' /** One raw log event plus its optional envelope-level presentation view. */ export interface ConversationEventInput { readonly event: SessionEvent - readonly view: ToolEventView | undefined + readonly view?: SessionToolView } /** Definition-local identity and lifecycle role extracted from one event. */ @@ -121,14 +121,6 @@ export interface ConversationViewSnapshotStore { ): ConversationViewSnapshotMap[Target] | undefined } -/** Final Chat render unit produced directly by a business Definition. */ -export interface ChatConversationViewNode extends ConversationViewNode { - readonly target: 'chat' - readonly anchorSeq: number - readonly location: ConversationLocation - readonly visibility: 'visible' | 'hidden' -} - /** Immutable public view of an assembled business Context. */ export interface ConversationNodeContext { readonly key: string @@ -261,6 +253,12 @@ export interface ConversationViewDefinition + /** + * Decide whether this target contributes visible Conversation activity. + * @param snapshot - latest target-owned snapshot. + * @returns whether the shell should treat this target as active. + */ + isActive?(snapshot: Snapshot): boolean } /** diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/contract/input.ts similarity index 72% rename from packages/client/ui-conversation/src/client/input/contract.ts rename to packages/client/ui-conversation/src/client/contract/input.ts index b9ace8b863..e3ad336313 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -5,14 +5,144 @@ * conversation wiring layer alone sees the full SessionInput. InputMachine * (machine.ts) is package-private and never exported. */ -import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from '@deepseek-ai/cordis' +import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { Branded } from '@deepseek-ai/dsh-brand' -import type { - ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, SubmitOutcome, TokenSpan, -} from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { QueueRow } from '../contract/queue.ts' -import type { InputSubmitMode } from '../contract/composer-submission.ts' +import type { QueueRow } from './queue.ts' +import type { InputSubmitMode } from './composer-submission.ts' + +/** Pick-time draft span guarded by the input revision. */ +export interface TokenSpan { + readonly start: number + readonly end: number + readonly draftRev: number +} + +/** Base64 image payload passed to a claimed command submission. */ +export interface SubmitImageAttachment { + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + readonly data: string + readonly name?: string +} + +/** Settled result of a command or default composer submission. */ +export interface SubmitOutcome { + readonly kind: 'success' | 'error' + readonly text?: string +} + +/** Command-mode credential supplied by one input-trigger source. */ +export interface CommandClaim { + readonly token: string + readonly hint?: string + readonly images?: boolean + /** + * Submit the claimed command. + * @param args - command text after the claimed token. + * @param actx - current Session scope. + * @param images - serialized draft images accepted by the claim. + * @returns command settlement. + */ + submit(args: string, actx: Context, images: readonly SubmitImageAttachment[]): Promise +} + +/** Structured reference inserted by an input-trigger source. */ +export interface ReferenceInsert { + readonly source: string + readonly ref: string + readonly label: string + readonly appearance?: 'session' | 'file' | 'folder' + readonly clipboardText: string +} + +/** Result of trigger-source adjudication. */ +export type PickOutcome = + | { readonly claim: CommandClaim } + | { readonly insert: ReferenceInsert } + | { readonly text: string; readonly continue?: boolean } + | 'handled' + | undefined + +/** Keyboard keys intercepted by an open trigger menu. */ +export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' + +/** Trigger-menu keyboard routing result. */ +export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass' + +/** Scoped request to enter command mode. */ +export interface BeginCommandRequest { + readonly claim: CommandClaim + readonly span: TokenSpan +} + +/** Scoped request to insert a structured reference. */ +export interface InsertReferenceRequest { + readonly reference: ReferenceInsert + readonly span: TokenSpan +} + +/** Scoped request to consume a command token after business settlement. */ +export interface ConsumeTokenRequest { + readonly guard: + | { readonly kind: 'span'; readonly span: TokenSpan } + | { readonly kind: 'bare-token'; readonly token: string } +} + +/** Scoped request to insert ordinary completion text. */ +export interface InsertTextRequest { + readonly text: string + readonly span: TokenSpan + readonly continue?: boolean +} + +/** Trigger hit used to open one source programmatically. */ +export interface InputTriggerHit { + readonly trigger: '/' | '@' + readonly query: string + readonly quoted: boolean + readonly position: 'leading' | 'inline' + readonly span: TokenSpan +} + +/** Structural per-Session trigger provider consumed by the input shell. */ +export interface InputTriggerController { + readonly launcher: ObservableSnapshot + readonly lexicon: ObservableSnapshot> + /** @param draft - current draft. @param caret - caret offset. @param guard - availability tier. @param draftRev - input revision. */ + track( + draft: string, + caret: number, + guard: { readonly tier: 'plain' | 'claimed' | 'frozen' }, + draftRev: number, + ): void + /** @param key - intercepted key. @param composing - whether IME composition is active. @returns routing result. */ + arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome + /** @returns whether Space applied a trigger result. */ + onSpace(): boolean + /** @param source - reference source. @param ref - source-local id. @param signal - submit cancellation. @returns model text. */ + serializeReference(source: string, ref: string, signal: AbortSignal): Promise + /** @param line - trimmed draft. @param signal - submit cancellation. @param envelope - attachment count. @returns winning result. */ + adjudicate( + line: string, + signal: AbortSignal, + envelope: { readonly images: number }, + ): Promise + /** @param source - source name. @param hit - synthetic trigger hit. */ + toggleSource(source: string, hit: InputTriggerHit): void +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** @param request - command claim and span. @mode bail */ + 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined + /** @param request - reference and span. @mode bail */ + 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined + /** @param request - token guard. @mode bail */ + 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined + /** @param request - plain text and span. @mode bail */ + 'slash/input-insert-text'(request: InsertTextRequest): true | undefined + } +} /** Browser-runtime identity of one unsent image draft. */ export type DraftAttachmentId = Branded<'DraftAttachmentId'> @@ -61,7 +191,7 @@ export interface SessionInput extends InputTarget { /** Session-addressed access to the per-session input facade. */ export interface SessionInputResolver { /** Resolve the facade for one session-scope ctx. */ - for(actx: ClientContext): SessionInput + for(actx: Context): SessionInput } /** @@ -223,7 +353,7 @@ export interface InputState { readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ readonly paste?: PasteAttemptState - /** Read-only transient inbox projection (`session/queue`, including pending steering). */ + /** Read-only transient inbox projection from Session control, including pending steering. */ readonly queue: readonly QueuedMessage[] } diff --git a/packages/client/ui-conversation/src/client/contract/queue.ts b/packages/client/ui-conversation/src/client/contract/queue.ts index 084cf13ade..4e54568448 100644 --- a/packages/client/ui-conversation/src/client/contract/queue.ts +++ b/packages/client/ui-conversation/src/client/contract/queue.ts @@ -1,13 +1,11 @@ -/** Queue contracts derived from the runtime session face and snapshot. */ -import type { - ConversationSnapshot, SessionFace, -} from '@deepseek-ai/dsh-client-runtime/client' +/** Queue contracts derived from the Session Controller face. */ +import type { SessionFace, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' -/** One address accepted by the runtime session's queue mutation verb. */ +/** One address accepted by the Session Controller's queue mutation verb. */ export type QueueItemId = Parameters[0] -/** One mutation accepted by the runtime session's queue mutation verb. */ +/** One mutation accepted by the Session Controller's queue mutation verb. */ export type QueueAction = Parameters[1] -/** One row projected by the runtime session's authoritative queue snapshot. */ -export type QueueRow = ConversationSnapshot['queue'][number] +/** One row projected by the authoritative Session queue snapshot. */ +export type QueueRow = SessionSnapshot['queue'][number] diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/ui-conversation/src/client/contract/records.ts similarity index 62% rename from packages/client/runtime/src/client/sessions/conversation.ts rename to packages/client/ui-conversation/src/client/contract/records.ts index daece0addb..b715814bcc 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -9,13 +9,9 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' import type { - RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView, + ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-api-remotes/client' -import type { PendingInteraction } from './pending.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' -import type { - ChatConversationViewNode, ConversationTimelineSnapshot, ConversationViewSnapshotStore, -} from '../contract/conversation.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -69,6 +65,20 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock { } } +/** + * Create the empty projection for one streamed Assistant block kind. + * @param blockType - wire block kind. + * @returns empty projected block ready to receive deltas. + */ +export function emptyAssistantBlock(blockType: string): AssistantBlock { + switch (blockType) { + case 'text': return { kind: 'text', text: '' } + case 'reasoning': return { kind: 'reasoning', text: '' } + case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' } + default: return { kind: 'other', block: null } + } +} + /** A finalized user message. */ export interface UserMessageNode { kind: 'user' @@ -310,169 +320,9 @@ export interface RunningToolCall { /** One running or settled call, recursively owning its child calls. */ export type ToolCallBlock = RunningToolCall | ToolResultNode -/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */ -export interface QueuedMessage { - readonly id: MessageId - /** Stable message identity used for transient-to-durable steering handoff. */ - readonly messageId: MessageId - /** Agent-resolved placement; only queued rows accept queue mutations. */ - readonly placement: 'queued' | 'steering' | 'context' - /** Complete content used to render pending steering before it becomes durable. */ - readonly content: readonly ContentBlock[] - readonly preview: string - /** Complete editable text; null when the message contains non-text blocks. */ - readonly text: string | null -} - /** In-progress assistant output (chunk accumulator product). */ export interface PartialAssistant { turn: number step: number blocks: readonly AssistantBlock[] } - -/** History-open lifecycle of a Session window. */ -export type OpenState = 'cold' | 'loading' | 'open' | 'error' - -/** - * Input-area shape of an OPEN session, derived at snapshot assembly (the one - * place that knows the predicate — consumers switch, never re-derive): - * - * - `blank`: the authoritative blank bit is still set and no prompt was - * attempted — the UI renders the blank-session guidance hero. - * - `engaging`: a first prompt was attempted, but no accepted turn or other - * authoritative activity signal has arrived — the UI keeps the composer - * visible through admission and error frames. - * - `active`: the session is non-blank beyond its pending first prompt, - * contains visible non-command Chat content, is running, or owns a pending - * interaction — the ordinary conversation view. - * - * A failed first prompt stays `engaging` (composer + error strip — retry - * semantics; returning to the hero would discard the error context). - * Sessions whose window is not open (`loading`/`error`) are outside phase - * jurisdiction: consumers branch on {@link ConversationSnapshot.openState} - * first. - */ -export type ComposerPhase = 'blank' | 'engaging' | 'active' - -/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ -export interface PromptError { - op: 'send' | 'stop' - error: RpcError -} - -/** - * Stable live per-key reader. An old ChatSnapshot observes later flushes - * through this store. - */ -export interface ChatNodeStore { - /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ - get(key: string): ChatConversationViewNode | undefined - /** @returns all currently materialized Nodes without imposing render order. */ - values(): readonly ChatConversationViewNode[] -} - -/** - * Stable live Location index. An old ChatSnapshot observes later membership - * changes through this index. - */ -export interface ChatLocationNodeIndex { - /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ - getTurn(turn: number): readonly string[] - /** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */ - getStep(turn: number, step: number): readonly string[] -} - -/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */ -export interface LegacyConversationSlice { - readonly nodes: readonly ConversationNode[] - readonly turnTimings: ReadonlyMap - readonly turnEnds: ReadonlyMap - readonly partial: PartialAssistant | null - readonly runningCalls: readonly RunningToolCall[] -} - -/** Incremental Chat publication with immutable order and stable live keyed readers. */ -export interface ChatSnapshot { - readonly order: readonly string[] - readonly nodes: ChatNodeStore - readonly locations: ChatLocationNodeIndex - readonly timeline: ConversationTimelineSnapshot - readonly legacy: LegacyConversationSlice -} - -const EMPTY_LIST: readonly never[] = [] -const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } - -/** Empty target store used by fixtures and Sessions without registered views. */ -export const EMPTY_CONVERSATION_VIEWS: ConversationViewSnapshotStore = { - get: () => undefined, -} - -/** Empty Chat target used before a view builder is registered. */ -export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { - order: EMPTY_LIST, - nodes: { - get: () => undefined, - values: () => EMPTY_LIST, - }, - locations: { - getTurn: () => EMPTY_LIST, - getStep: () => EMPTY_LIST, - }, - timeline: EMPTY_TIMELINE, - legacy: { - nodes: EMPTY_LIST, - turnTimings: new Map(), - turnEnds: new Map(), - partial: null, - runningCalls: EMPTY_LIST, - }, -} - -/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ -export interface ConversationSnapshot { - sessionId: SessionId - /** Registered target snapshots assembled from Session events. */ - views: ConversationViewSnapshotStore - /** Final Chat target assembled from independently registered business Definitions. */ - chat: ChatSnapshot - /** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */ - nodes: readonly ConversationNode[] - /** Exact in-window `turn/start` time and optional matching `turn/end` time. */ - turnTimings: ReadonlyMap - /** In-window completed turn number -> its `turn/end` event seq. */ - turnEnds: ReadonlyMap - partial: PartialAssistant | null - runningCalls: readonly RunningToolCall[] - pending: readonly PendingInteraction[] - /** Authoritative transient inbox snapshot, including queued and steering placements. */ - queue: readonly QueuedMessage[] - running: boolean - /** - * Catalog-discovered continuation address. Its parent availability controls - * human input; null means ordinary session transport. - */ - subagent: { address: SubagentAddress; parentAvailable: boolean } | null - /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */ - composerPhase: ComposerPhase - /** Set after host/session-removed; the UI grays out and disables input. */ - removed: boolean - openState: OpenState - openError: RpcError | null - hasMore: boolean - loadingOlder: boolean - promptError: PromptError | null - /** - * Whether this session still has an empty log (no user message yet). - * Mirrors the host summary's derived blank bit: seeded from `session.list` - * / the `host/session-added` frame, flipped false by the first ACCEPTED - * prompt locally (on the RPC success response — acceptance proves the - * user message is in the host log; a rejected first prompt keeps the - * session blank and reusable) and by any `running: true` status remotely, - * and re-aligned by every list re-pull (the summary stays authoritative). - * Blank sessions are hidden from session lists and reused by New Session. - */ - blank: boolean - lastAgentError: string | null -} diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/ui-conversation/src/client/contract/request-inspection.ts similarity index 98% rename from packages/client/runtime/src/client/sessions/request-inspection.ts rename to packages/client/ui-conversation/src/client/contract/request-inspection.ts index 9856bce2bc..8c631fa346 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/ui-conversation/src/client/contract/request-inspection.ts @@ -1,11 +1,11 @@ import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { AssistantProvenanceView, AssistantRequestConfig, -} from './conversation.ts' +} from './records.ts' export type { AssistantProvenanceView, AssistantRequestConfig, -} from './conversation.ts' +} from './records.ts' /** Complete model-visible request header in force for an ordinary generation. */ export interface ConversationPromptSnapshot { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 307ac38482..57c686383b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,28 +1,27 @@ -/** Conversation slot declarations and their composed component props. */ +/** Target-neutral Conversation slot declarations and composed component props. */ import type { ReactNode, RefObject } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { - InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, - SlotHookFactory, SnapshotSelectorHook, + MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook, +} from '@deepseek-ai/dsh-client-store' +import type { + InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' -import type { - CommandNode, CompactionSummaryNode, ConversationSnapshot, ConversationTurnDataMap, - ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, - TurnLocation, WorkspaceId, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' -import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionPendingInteraction } from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { ComposerBlock } from '../input/blocks.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { ComposerBlock } from './composer-blocks.ts' import type { ComposerKeyboard, DraftAttachmentId, EditSelection, InputActions, InputNotice, InputState, -} from '../input/contract.ts' -import type { createChatStore } from '../stores.ts' +} from './input.ts' +import type { createConversationStore } from '../stores.ts' import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts' -import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' -import type { CallId, SelectionTarget, ViewTab } from './views.ts' +import type { ConversationSnapshot } from './snapshot.ts' +import type { ViewTab } from './views.ts' -/** Browser-owned image that has not crossed the durable host boundary. */ +/** Browser-owned image that has not crossed the durable Host boundary. */ export interface ComposerAttachment { kind: 'image' id: DraftAttachmentId @@ -38,570 +37,217 @@ export interface ComposerAttachmentsOwnerProps { canAcceptDrop: boolean /** Add one dropped batch through the composer's validation path. */ onAddImages: (files: readonly File[]) => void - /** Remove one draft image through the conversation service. */ + /** Remove one draft image through the Conversation service. */ onRemoveImage: (id: DraftAttachmentId) => void /** Display-ready limits for the drop invitation. */ dropLimits?: { readonly count: number; readonly size: string } | undefined } -/** Historical image group handed to the optional attachment presentation plugin. */ -export interface MessageImagesOwnerProps { - /** Consecutive image blocks rendered as one gallery. */ - images: readonly { readonly attachment: ImageAttachmentRef }[] - /** Session-authorized durable image loader. */ - loadImage: (attachment: ImageAttachmentRef) => Promise - /** Message-side alignment. */ - align: 'start' | 'end' -} - -/** Slot-backed renderer used by chat nodes without importing an attachment implementation. */ -export type RenderMessageImages = (owner: Omit) => ReactNode +/** Selector hook over the current Session's assembled Conversation. */ +export type UseConversation = SnapshotSelectorHook +/** Selector hook over the registered Conversation View roster. */ +export type UseConversationViews = SnapshotSelectorHook declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { - /** - * The entire body of one session: taking this seat means rendering that - * session's conversation yourself. The occupant also owns the per-session - * draft mirror and the active view ring, so a replacement inherits both - * duties and an empty one leaves a blank session pane — nothing here - * degrades gracefully. To ADD rather than replace, take a seat inside the - * flow instead: `conversation.view` for a whole tab, the input regions for - * composer chrome. - */ + /** Strict per-Session Conversation body. */ 'conversation.session': { kind: 'single'; scope: 'session' } - /** - * The strip above the session's scrollport: title, view tabs, and the - * action row. Taking this seat means rendering all three yourself, and it - * also collapses `conversation.session.header.actions` — that additive - * seat is declared by whoever occupies this one, so replacing the header - * takes every action entry down with it. - */ + /** Strict per-Session title, actions, and View navigation. */ 'conversation.session.header': { kind: 'single'; scope: 'session' } - /** - * One breadcrumb title and its lineage controls. The render site keeps - * the ordinary title as fallback; an occupant receives plain title data - * and may replace a subagent title with one combined navigation control. - */ + /** Optional replacement for one Session breadcrumb title. */ 'conversation.session.header.lineage': { kind: 'single' scope: 'session' owner: ConversationHeaderLineageOwnerProps } - /** - * One button in the session header's action row — the additive way to put - * a per-session control beside the title without replacing the header. - * Entries render by ascending `order`; negative values are reserved for - * static session context that precedes interactive actions. The owner - * passes nothing: everything a control needs comes from the framework - * session kit (`sessionId`, `useSession`, `useInput`, `inputActions`) and - * from the registrant's own inject face, so an empty owner share means - * self-sufficient, not starved. - */ - 'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } - /** - * Right-aligned Session utilities kept outside the title-adjacent action - * group, so an optional utility cannot reorder session context or lineage. - */ - 'conversation.session.header.utilities': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } - /** - * The conversation view ring: one list entry per view tab (chat here; - * trajectory/waterfall from ui-trajectory), rendered one-at-a-time by - * the session body via `only: `. Declared by this package's - * body entry (declaring is claiming). Session scope: views read the - * conversation snapshot through the standard kit. - */ - 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } - /** Final business node renderer, dispatched by `ChatConversationViewNode.kind`. */ - 'conversation.chat.node': { - kind: 'keyed' - scope: 'session' - owner: ChatNodeOwnerProps - keyProps: { [Kind in ChatNodeKind]: { node: ChatNode } } - hookContext: string - inject: ChatNodeTurnDataInjected - } - /** Optional renderer for one consecutive group of durable message images. */ - 'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } - /** - * The chat view's per-command row hole: keyed dispatch on the command - * name (`command/run.name`; a run-less cross-window node has none and - * always lands on the fallback). Declared by the chat view entry; the - * render site dispatches via `entryKey: name` with GenericCommandCard as - * the `fallback` — a slash command renders durably with zero - * registration, and a domain upgrades by registering one row component. - */ - 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } - /** - * The completed Turn Node's extension chain, rendered before that Node's - * IconActions. Entries derive a match from the engine-owned Turn and - * closing seq before mounting, so presentation components never mount - * only to return null; an all-declined chain renders nothing. - */ - 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } - /** - * Action strip attached to one finalized assistant message, rendered - * inside that message's IconActions row. The chat entry owns the render - * site and passes the addressed message identity; contributors add - * per-message actions without importing the conversation implementation. - * Entries render by ascending `order`. - */ - 'conversation.chat.assistant-actions': { + /** Title-adjacent Session actions in ascending order. */ + 'conversation.session.header.actions': { kind: 'list' scope: 'session' - owner: AssistantActionOwnerProps + owner: ConversationHeaderActionOwnerProps } - /** - * The body of the details panel for the tool call the user selected — - * one occupant, so taking it means rendering every tool's output, not just - * the ones you know. The owner passes a frozen `block` whose two lifecycle - * forms must both be handled: branch on `'kind' in block` (a settled - * `ToolResultNode` has it, a still-running call does not), and treat - * `cwd` as display-only, for shortening workspace-rooted paths. - * A per-tool renderer belongs in the keyed `tool.call.toolview` seat - * instead; this one is the whole panel. - */ - 'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps } - /** - * The composer takeover chain: entries are selector-routed replacements - * of the default InputBar. Declared by this package's 'conversation' - * entry; the owner dispatches the {@link ComposerChainProps} currency and - * routing lives in entry selectors — new takeover kinds register with - * zero owner changes. - */ + /** Right-aligned Session utilities in ascending order. */ + 'conversation.session.header.utilities': { + kind: 'list' + scope: 'session' + owner: ConversationHeaderActionOwnerProps + } + /** Registered Conversation target Views, rendered one at a time. */ + 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } + /** Selector-routed replacements for the current Session's resident composer. */ 'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps } - /** - * The hero-phase Workspace picker hole: rendered by ConversationRoot - * while the session is blank (picking another workspace switches to that - * workspace's blank session, draft carried). Root scope: the picker - * reads the global workspace list. - */ + /** Workspace picker shown by the blank-session Hero. */ 'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps } - /** - * Brand mark leading the blank-session headline. Declared by this - * package's `conversation` entry; the shell supplies a fish fallback. - */ + /** Brand mark shown before the blank-session headline. */ 'conversation.hero.brand.mark': { kind: 'single'; scope: 'root'; owner: HeroBrandMarkOwnerProps } - /** - * The agent-preset chip beside the workspace picker on the new-session - * screen. Root scope: no session exists yet, so the choice is staged for - * the next one rather than applied to a current one. - */ + /** Agent-preset control staged for a New Session. */ 'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps } - // 'conversation.input.overlay' merges in ui-input-trigger (the dependency - // direction is the hard constraint — ui-input-trigger cannot import - // this package, while this package's input contract already imports - // ui-input-trigger, so the type arrives transitively). The runtime declaration - // (children table in apply.ts) stays here with the other input slots. - /** - * A full-width row of its own, stacked above the composer card — the seat - * for anything that needs a line to itself (queue rows, a todo strip, a - * goal bar). Pick this over the three seats below when your content wraps - * or carries prose; pick `conversation.composer.dock` for an ambient - * readout under the card, and `conversation.input.left` / - * `.right` for a small control INSIDE the card's tool row. - * Read only `session`/`input` off the owner share ({@link InputZone}) — - * both are point-in-time snapshots re-rendered for you, never subscribe. - */ + /** Full-width entries above the composer card. */ 'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone } - /** - * The band under the composer card, inside the bar's width column — the - * seat for an ambient readout about the conversation (the shipped stats - * line lives here). Same {@link InputZone} owner share as the other - * regions. Anything the user must click belongs in the tool row instead - * (`conversation.input.left` / `.right`); anything needing its own line - * above the card belongs in `conversation.input.dock`. - */ + /** Floating entries rendered inside the resident composer card. */ + 'conversation.input.overlay': { kind: 'list'; scope: 'session' } + /** Ambient entries below the composer card. */ 'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone } - /** - * The left end of the tool row INSIDE the composer card, after the - * resident chrome (access mode, plan, attach) — the seat for a small - * always-visible control. Entries sit beside that chrome, never replace - * it. Same {@link InputZone} owner share; use `.right` for a control that - * belongs next to the send button, and the docks for anything taller than - * one row. - */ + /** Compact controls at the left of the composer tool row. */ 'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone } - /** - * The right end of the same tool row, before the primary send button — - * the seat for a control the user reaches on the way to sending (the - * model select sits in its own named seat just left of here). Same - * {@link InputZone} owner share and the same one-row height budget as - * `conversation.input.left`. - */ + /** Compact controls before the composer submit action. */ 'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone } - /** - * The default composer body: a single slot rendered as the composer - * chain's fallback (a real entry, not a chain rider, so a - * takeover election hides rather than unmounts it and the textarea DOM - * survives). Session-maybe: the bar stays mounted across the - * no-session/session transition — the no-workspace hero renders the SAME - * textarea DOM as a read-only Workspace-picker trigger instead of a - * parallel inert tree — with the machine hooks absent until a session is - * current. InputBar registers - * here from this package's apply; its machine state arrives through the - * standard provide channel (useInput + inputActions), the keyboard - * command face through its own inject. - */ + /** Resident composer body, including the no-Session inert state. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } - /** Optional draft-image rail, drop target, and preview surface inside the composer. */ + /** Optional draft-image rail and drop target. */ 'conversation.input.attachments': { kind: 'single' scope: 'session-maybe' owner: ComposerAttachmentsOwnerProps } - /** - * The named plan-status seat in the composer tool row, immediately right - * of the access-mode control — one occupant, so taking it means rendering - * the plan affordance yourself. The owner passes only `locked` (see - * {@link InputControlOwnerProps}): honour it by refusing interaction, and - * take everything else from the framework session kit or your own inject. - * Unoccupied, the seat renders nothing at all — the bar paints no - * placeholder, so an absent plan plugin costs no layout. - */ + /** Plan control inside the composer tool row. */ 'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } - /** - * The named model-select seat at the right end of the composer tool row, - * left of the send button — one occupant, so taking it means rendering the - * whole model affordance yourself. Same `locked`-only owner share and same - * renders-nothing-while-empty contract as the plan seat. Note the composer - * deliberately keeps this seat LIVE while it refuses text for a - * model-related block: every such block is one the user clears by picking - * a model here. - */ + /** Model selector inside the composer tool row. */ 'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } } - /** - * ui-conversation's members of the session standard kit, provided through - * `sessions.provide`: every session-scope slot component - * receives the input machine's state hook and the two public actions. - */ + interface GlobalStandardProps { + /** Workspace selector supplied by the independently loaded Workspace UI. */ + useWorkspaces: SnapshotSelectorHook + } + interface SessionStandardProps { - /** Selector hook over the session's live input machine state. */ + /** Selector hook over target-neutral Conversation assembly. */ + useConversation: UseConversation + /** Selector hook over the Session input machine. */ useInput: SnapshotSelectorHook - /** The public input action face (stable identity per session). */ + /** Stable public input actions for this Session. */ inputActions: InputActions } - /** Input members for the resident composer while current session is optional. */ interface SessionMaybeStandardProps { + /** Selector hook whose values are absent without a current Session. */ + useConversation: MaybeSnapshotSelectorHook + /** Input values are absent without a current Session. */ useInput: MaybeSnapshotSelectorHook + /** Input actions are absent without a current Session. */ inputActions: InputActions | undefined } } -/** Owner share of the hero agent-preset chip: the shell supplies nothing. */ +/** Owner share of the Hero agent-preset control. */ export interface HeroAgentPresetOwnerProps { - /** Marker field: the chip owns its own roster, staging, and menu state. */ + /** Marker field: the occupant owns its roster and staged selection. */ children?: never } -/** Owner share of the strict session content seat. */ -export interface ConversationSessionOwnerProps { - /** - * Wrap the view ring in the transcript scrollport that also hosts the - * sticky composer seat (whole `'conversation.composer'` chain output). - * Supplied for every real session (hero/settling/active) so the composer - * keeps one tree seat across the blank → active flip; the header stays - * outside that wrapper as ordinary column chrome (`flex: none`), while - * active CSS sticks the seat to the bottom of the same scrollport so wheel - * over the footer scrolls the flow. - * @param view - the session view-ring content (null while blank chrome is hidden). - * @returns the scrollport containing `view` and the sticky composer seat. - */ - wrapActiveBody?: (view: ReactNode) => ReactNode +/** Header actions derive their state from standard Session props. */ +export interface ConversationHeaderActionOwnerProps { + /** Marker field: entries receive no owner-specific values. */ + children?: never } -/** Header actions derive their state from the standard session/global kit. */ -export interface ConversationHeaderActionOwnerProps {} - /** Plain breadcrumb data handed to the optional lineage renderer. */ export interface ConversationHeaderLineageOwnerProps { /** Session represented by this breadcrumb title. */ lineageSessionId: SessionId - /** Display title available to a renderer that combines the title with a control. */ + /** Display title available to a combined title/control renderer. */ displayTitle: string - /** Navigate to an ancestor title when its combined control is clicked. */ + /** Navigate to an ancestor title when present. */ openTitle?: () => void } -/** - * The input-region slot currency: dock/left/right entries read - * the conversation snapshot and the live input state as owner props (both - * are point-in-time snapshots — the dispatching skeleton re-renders on - * either store's change, so entries stay current without subscribing). - */ +/** Point-in-time owner values for composer extension entries. */ export interface InputZone { - readonly session: ConversationSnapshot + readonly session: SessionSnapshot readonly input: InputState } -/** - * View-slot owner share: the cross-view inspect handoff (otherwise views need - * nothing from the render site — sessionId and the snapshot hook arrive as - * framework-standard props; tool rows go through each view's own declared - * toolview hole). - */ +/** Conversation View entries obtain their data from registered standard hooks. */ export interface ConvViewOwnerProps { - /** One-shot inspect request from another view (chat's Inspect button); null when idle. */ - inspect?: { callId: CallId } | null - /** Acknowledge the inspect request once applied (clears the store field). */ - onInspectDone?: () => void + /** Focus request addressed to the selected View. */ + viewRequest: import('./views.ts').ConversationViewRequest | null + /** Select a View and address one opaque focus identity to it. */ + openView: (view: string, focus: string) => void + /** Acknowledge the current one-shot focus request. */ + completeViewRequest: () => void } -/** - * Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')` - * (optional-service convention): the chat view asks it for a closing message's - * inline-code vocabulary and threads the result into MarkdownText. Absent - * service — the providing plugin composed out of cordis.yml — turns the - * surface off; the prose renders inert code. - */ -export interface ChatFileMentions { - /** - * Mention vocabulary for the closing message the owner currency names. - * @param owner - Turn-tail owner currency (Turn data, closing seq, opener). - * @returns The resolver MarkdownText consumes, or undefined when the turn - * produced nothing worth linking. - */ - forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined -} - -declare module '@deepseek-ai/cordis' { - interface Context { - /** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */ - chatFileMentions: ChatFileMentions - } -} - -/** - * Owner currency of the chat view's turn-tail hole: the engine-owned Turn and - * the closing assistant's anchor. Registrants read their own typed Turn data - * and open files through the same opener the tool rows use. - */ -export interface TurnTailOwnerProps { - /** Engine-owned closing Turn boundary. */ - turn: TurnLocation - /** The closing assistant's seq — the anchor the tail renders under. */ - seq: number - /** - * Open a filesystem path through the Host (tool-row semantics; the chat - * view resolves relative paths against the session cwd). - */ - openFile: (path: string) => void -} - -/** - * Owner currency of the assistant-message action strip: the durable identity - * of the one finalized message the contributed actions address. Only finalized - * messages reach this slot, so the id is always present. - */ -export interface AssistantActionOwnerProps { - /** Stable identity carried from the `assistant/message` event. */ - messageId: MessageId -} - -/** Hook constrained to business data published on the current Chat Node's Turn. */ -export type UseChatNodeTurnData = >( - key: Key, -) => Readonly | undefined - -/** Slot-level Hook factory used by renderers reading their Node's Turn data. */ -export interface ChatNodeTurnDataInjected { - hooks: { - turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> - } -} - -/** Stable owner currency delivered to one keyed Chat business renderer. */ -export interface ChatNodeOwnerProps { - /** Selected Tool call, when the shared details store names one. */ - selectedCallId?: CallId | undefined - /** Session workspace root; Tool summaries display paths relative to it. */ - cwd?: string | undefined - openFile: (path: string) => void - inspectCall: (callId: CallId) => void - forkAt: (seq: number) => void - /** Render a historical image group through the attachment slot. */ - renderMessageImages: RenderMessageImages - fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined -} - -/** Full props of one registered keyed Chat business renderer. */ -export type ChatNodeViewProps = - PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'conversation'> - -/** Owner currency of the details panel's Tool output renderer. */ -export interface DetailsToolOwnerProps { - /** Frozen selected call slice. */ - block: ToolCallBlock - /** Session workspace root for card cwd and relative-path display. */ - cwd?: string | undefined -} - -/** - * Owner share of the per-command row slot: the frozen {@link CommandNode} - * slice off the snapshot (cache-stable reference — memo premise). The node - * carries the whole lifecycle (structured name/args, pairing id, and - * outcome-or-executing). A successful domain command may also carry the - * explicitly linked projection node needed to fold two log records into one - * presentation row. - */ -export interface CommandRowOwnerProps { - /** Folded command lifecycle node (run + optional done). */ - node: CommandNode - /** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */ - compaction?: CompactionSummaryNode -} - -/** Full props of a registered command-row component. */ -export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'> - -/** - * Base props of a conversation view entry: the framework standard kit for the - * session-scope 'conversation.view' slot (useSession narrowed to the - * conversation snapshot by the runtime merge, sessionId, useSessions). - * Entries declaring the shared store or an inject face compose their shares - * on top (the chat entry's {@link ChatViewSlotProps}); store-less pure - * readers (ui-trajectory) take this base alone. - */ +/** Base props of one target-owned Conversation View entry. */ export type ConvViewProps = PropsRuntime<'conversation.view'> -/** The shared chat store handle type declared by the Session header/body, details, and chat-view registrations. */ -export type ChatStore = ReturnType - -/** Business callbacks injected into the conversation slot. */ +/** Business callbacks injected into the resident Conversation shell. */ export interface ConversationInjected { - /** - * Connect the selected Workspace and open its reusable/new blank session. - * When a blank session is already current, carry its draft to the target. - */ + /** Connect and open a blank Session in the selected Workspace. */ selectWorkspace: (workspaceId: WorkspaceId) => Promise - /** - * Framework-bound sources. `composerBlock` is this session's block when a - * plugin raised one; the reason is the blocker's own localized copy, which - * the root renders as the inert composer's placeholder. - */ + /** Session-addressed composer block source, or the stable absent source. */ hooks: { composerBlock: ObservableSnapshot } } -/** Business callbacks injected into the strict Session body seat. */ +/** Business callbacks injected into the strict Session body. */ export interface ConversationSessionInjected { - /** Views projected from the `conversation.view` slot ledger. */ - views: { - list: () => readonly ViewTab[] - subscribe: (fn: () => void) => () => void - version: () => number - } - /** Release historical image URLs when this rendered session scope unmounts. */ - releaseSessionImages: (sessionId: SessionId) => void - /** Bind the input machine's draft persistence mirror to the session store. */ + /** Package-owned View roster source bound only for the Conversation body. */ + readonly hooks: { readonly conversationViews: ObservableSnapshot } + /** Bind input draft persistence to the Session-owned store instance. */ bindDraftMirror: (write: (text: string) => void) => () => void } -/** Business callbacks injected into the strict session header seat. */ +/** Business callbacks injected into the strict Session header. */ export interface ConversationSessionHeaderInjected { - /** Views projected from the `conversation.view` slot ledger. */ - views: { - list: () => readonly ViewTab[] - subscribe: (fn: () => void) => () => void - version: () => number - } - /** Select a real Session through the runtime navigation owner. */ + /** Package-owned View roster source bound only for the Conversation header. */ + readonly hooks: { readonly conversationViews: ObservableSnapshot } + /** Select a Session through the Session Controller. */ open: (sessionId: SessionId) => void } -/** - * Owner share of the composer-bar slot: ConversationRoot's layout-phase - * inputs plus the input-region child-slot content it renders (the region - * slots stay declared/rendered by the conversation entry; the bar hosts the - * results as chrome). - */ +/** Owner share of the resident composer bar. */ export interface ComposerBarOwnerProps { - /** Hero = empty-state centered card; composer = resident bottom bar. */ + /** Hero uses centered placement; composer uses the active bottom placement. */ variant: 'hero' | 'composer' - /** - * A block another plugin raised for this session: the bar refuses input and - * shows the blocker's reason as the placeholder, but — unlike `disabled` — - * keeps the model seat live. Every block this contract has is one the user - * clears by choosing a model, so locking that seat too would leave the - * composer telling them to do the one thing it prevents. - */ + /** A feature-owned reason that makes message input inert while leaving model selection live. */ blocked?: { readonly reason: string } - /** - * Inert no-workspace state: the bar locks message actions while preserving - * its normal DOM so the Workspace pick transitions in place. - */ + /** Lock all message actions while preserving the resident textarea. */ disabled?: boolean - /** Whether the shared Workspace picker menu is expanded, regardless of which trigger opened it. */ + /** Whether the shared Workspace picker is expanded. */ workspacePickerOpen?: boolean - /** Open the existing Workspace picker from the inert textarea. */ + /** Open the Workspace picker from the inert textarea. */ onRequestWorkspace?: () => void placeholder?: string /** Optional content rendered above the textarea. */ accessory?: ReactNode - /** Floating overlay anchor content (menu / popup shell entries), rendered inside the card. */ + /** Floating overlay content rendered inside the composer card. */ overlay?: ReactNode - /** input.left slot entries (tool row, beside the resident chrome). */ + /** Left-side input controls. */ leftItems?: ReactNode - /** input.right slot entries (tool row, before the primary button). */ + /** Right-side input controls. */ rightItems?: ReactNode - /** composer.dock entries (stats line), rendered under the card inside the bar's width column. */ + /** Ambient content below the card. */ footer?: ReactNode } -/** Injected share of the composer-bar entry (package-internal faces). */ +/** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { - /** The InputBar-exclusive keyboard/DOM command face (private plane); absent with the session. */ keyboard: ComposerKeyboard | undefined - /** Create previews and append image ids to the session input. */ addImages: ((files: readonly File[]) => string | null) | undefined - /** Release one preview and remove its id from session input. */ removeImage: ((id: DraftAttachmentId) => void) | undefined - /** Resolve ordered input ids to browser-owned draft images. */ draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined - /** Resolve one keyboard submission gesture against the current running state and persisted preference. */ resolveSubmitMode: ( running: boolean, gesture: ComposerSubmitGesture, steeringAvailable: boolean, ) => InputSubmitMode - /** Toggle the shared slash menu with only its command source; absent without ui-input-trigger or a session. */ toggleCommandMenu: ((selection: EditSelection) => void) | undefined - /** Cancel the in-flight turn; absent with the session. */ stop: (() => void) | undefined - /** - * Submit one slash-command line against this session's agent (the chrome - * controls' write path — the permission chip submits `/permission `); - * absent with the session. - * Resolves admission: false = rejected/unmatched/transport failure. - */ command: ((line: string) => Promise) | undefined - /** - * Registrant hooks compartment: the renderer binds these to - * useNotices/useLexicon (static absent sources without a session — hook - * order stays constant). - */ hooks: { - /** Latest surfaced notice (null after none; seq keys re-render of repeats). */ notices: ObservableSnapshot - /** Hot plain-text reference lexicon for the decoration scan (plain-text-reference decision; - * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md). */ lexicon: ObservableSnapshot> - /** Source name opened by the programmatic menu launcher, or null. */ menuLauncher: ObservableSnapshot } } -/** - * Owner share of the two named composer control seats (plan / model): the - * bar passes its disable state; the filling entry owns everything else. - */ +/** Owner share of the named plan and model controls. */ export interface InputControlOwnerProps { - /** Session-removed lock (the bar's chrome disable state). */ + /** Whether the composer currently refuses interaction. */ locked: boolean } -/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */ +/** Full props of the resident composer bar. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> & PropsRenderSlots< @@ -610,34 +256,28 @@ export type ComposerBarProps = & InjectFace & PropsLocale<'conversation'> -/** - * Composer chain currency: what ConversationRoot dispatches at its - * renderSlotChain site. The owner declares the currency only — never a - * per-entry contract; takeover packages narrow it in their own selectors - * (`interactions.find(i => i.kind === ...)`), so new takeover kinds register - * with zero owner changes. - */ +/** Owner values used to elect a composer takeover. */ export interface ComposerChainProps { - interactions: readonly PendingInteraction[] - /** Current conversation facts for feature-owned takeover selectors. */ - session: ConversationSnapshot | undefined + /** Current Session identity used by temporary business-owned entries. */ + sessionId: SessionId | undefined + /** Current Session lifecycle state, absent without a selected Session. */ + session: SessionSnapshot | undefined + /** Effective business-owned interaction awaiting the user in this Session. */ + pendingInteraction: SessionPendingInteraction | undefined } -/** Presentation props supplied to the blank-session brand-mark occupant. */ +/** Presentation props supplied to the blank-session brand mark. */ export interface HeroBrandMarkOwnerProps { /** Requested square edge in pixels. */ size: number - /** Host CSS class for preserving the default hero mark color and hover motion. */ + /** Host class preserving the surrounding mark geometry. */ className?: string | undefined } -/** - * Full conversation-slot component props: runtime & child-render (view ring - * + composer chain/bar + input-region + hero picker slots) & store & injected - * shares & the locale seat. - */ +/** Full props of the resident optional-Session Conversation shell. */ export type ConversationSlotProps = - PropsRuntime<'conversation'> & PropsRenderSlots< + PropsRuntime<'conversation'> + & PropsRenderSlots< | 'conversation.session' | 'conversation.session.header' | 'conversation.composer' | 'conversation.composer.bar' | 'conversation.input.overlay' @@ -650,14 +290,17 @@ export type ConversationSlotProps = & InjectFace & PropsLocale<'conversation'> -/** Full strict-session body props: per-session store, view ring, and draft mirror. */ +/** Shared target-neutral Conversation store handle. */ +export type ConversationStore = ReturnType + +/** Full props of the strict Session body. */ export type ConversationSessionSlotProps = PropsRuntime<'conversation.session'> & PropsRenderSlots<'conversation.view'> - & PropsStore - & ConversationSessionInjected + & PropsStore + & InjectFace -/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */ +/** Full props of the strict Session header. */ export type ConversationSessionHeaderSlotProps = PropsRuntime<'conversation.session.header'> & PropsRenderSlots< @@ -665,156 +308,19 @@ export type ConversationSessionHeaderSlotProps = | 'conversation.session.header.actions' | 'conversation.session.header.utilities' > - & PropsStore - & ConversationSessionHeaderInjected + & PropsStore + & InjectFace & PropsLocale<'conversation'> -/** The pending approval carrier the owner dispatches into the composer chain. */ -export type ApprovalWait = PendingWait<'approval'> - -/** - * Approval domain face over the carrier (the ui-user-questions PendingQuestion - * pattern): render identity and question material forwarded transparently; - * answer owns the wire encoding — the ApprovalResponsePayload value shape - * with the audit correlation the host reconciles — and turns a rejected - * carrier receipt into a thrown error. Minted per carrier via useMemo. - */ -export class PendingApproval { - /** - * @param wait - the runtime carrier for one pending approval question. - */ - constructor(private readonly wait: ApprovalWait) {} - - /** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */ - get key(): string { - return this.wait.key - } - - /** The tool the question is about (headline fallback), forwarded from the carrier payload. */ - get toolName(): string { - return this.wait.payload.toolName - } - - /** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */ - get reason(): string | undefined { - return this.wait.payload.reason - } - - /** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */ - get callId(): string | undefined { - return this.wait.payload.callId - } - - /** - * Deliver the user's decision; a rejected carrier receipt throws. Panel - * removal stays frame-driven: the broadcast `approval/resolved` settles the - * wait and drops it from the pending list. - * @param outcome - the only two client-answerable outcomes. - */ - async answer(outcome: 'allowed-once' | 'rejected'): Promise { - const receipt = await this.wait.respond({ - ok: true, - value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome }, - }) - if (!receipt.accepted) { - throw new Error(`approval response rejected: ${receipt.reason}`) - } - } -} - -/** - * Full approval-composer props: the framework runtime share (chain currency + - * session/global standard kit) plus the chain `matched` share — the entry's - * selector result, already narrowed to the approval carrier — plus the - * standard locale seat. No injected share: the carrier plus the domain face - * above carry the whole behavior surface; the paired command line derives - * from useSession in-component. - */ -export type ApprovalComposerProps = - PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'> - -/** In-memory reader position resilient to transcript width reflow. */ -export interface ChatScrollPosition { - /** Stable rendered node/call identity nearest the visible reading edge. */ - readonly anchorKey: string - /** Anchor top relative to the transcript scrollport when saved. */ - readonly anchorTop: number - /** Approximate offset used before the semantic anchor is measured. */ - readonly scrollTop: number -} - -/** - * Injected share of the chat view entry: the two callbacks whose targets live - * outside the view (layout orchestration; the session object layer). - */ -export interface ChatViewInjected { - /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ - openDetails: (target: SelectionTarget) => void - /** - * Open a tool-arg filesystem path with the host OS default application - * (relative paths resolve against the session cwd). Always returns a - * promise: fulfills when the Host opens the path, rejects when it cannot - * hand the path off (the chat view shows that reason and a retry). - */ - openFile: (path: string) => Promise - loadOlder: () => void - /** Resolve a session-authorized historical image for inline display. */ - loadImage: (attachment: ImageAttachmentRef) => Promise - /** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */ - inspectCall: (callId: CallId) => void - /** - * Per-session scroll memory surviving view switches (in-memory, never - * persisted): the view saves on every scroll and restores on remount; a - * fresh page load starts empty and keeps the open-jump-to-bottom default. - */ - chatScroll: { - /** Record a semantic reader position; null clears it when pinned. */ - save: (position: ChatScrollPosition | null) => void - /** Last reader position, or null when pinned or never recorded. */ - read: () => ChatScrollPosition | null - } - /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ - forkAt: (seq: number) => void - /** - * Prose file-mention vocabulary for one closing message, from the optional - * {@link ChatFileMentions} service (resolved lazily per call, so composing - * the provider in or out takes effect live). Undefined when the service is - * absent or the turn produced nothing worth linking. - */ - fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined -} - -/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */ -export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> - & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'> - & PropsStore & ChatViewInjected & PropsLocale<'conversation'> - -/** Full props of the attachment plugin's composer entry. */ +/** Full props of the draft-image attachment renderer. */ export type ComposerAttachmentsProps = PropsRuntime<'conversation.input.attachments'> & PropsLocale<'conversation'> -/** Full props of the attachment plugin's message-gallery entry. */ -export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'> - -/** - * Injected share of the details slot: the panel is otherwise a pure reader of - * the shared chat store, but its close button is a layout orchestration call. - */ -export interface DetailsInjected { - /** Close the details panel (layout geometry stays with ctx.layout). */ - closeDetails: () => void -} - -/** Full details-slot props: selection store, Tool output seat, injected close callback, and locale. */ -export type DetailsSlotProps = PropsRuntime<'details'> & PropsRenderSlots<'conversation.details.tool'> - & PropsStore & DetailsInjected & PropsLocale<'conversation'> - -/** Owner share common to the hero / New-Session Workspace pickers. */ +/** Owner share common to blank-session Workspace pickers. */ export interface EmptyWorkspaceOwnerProps { open: boolean anchorRef?: RefObject - /** Currently active workspace (renders a trailing check in the picker list). */ + /** Currently selected Workspace, when available. */ selectedId?: WorkspaceId | undefined onPick: (workspaceId: WorkspaceId) => void onClose: () => void diff --git a/packages/client/ui-conversation/src/client/contract/snapshot.ts b/packages/client/ui-conversation/src/client/contract/snapshot.ts new file mode 100644 index 0000000000..4b3fa13a9f --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/snapshot.ts @@ -0,0 +1,34 @@ +/** Target-neutral Conversation state assembled from one Session event window. */ +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ConversationViewSnapshotStore } from './conversation.ts' + +/** Latest registered target snapshots and their shell-level activity. */ +export interface ConversationSnapshot { + readonly views: ConversationViewSnapshotStore + readonly activeTargets: ReadonlySet +} + +/** Empty Conversation value used before a Session binding is available. */ +export const EMPTY_CONVERSATION_SNAPSHOT: ConversationSnapshot = { + views: { get: () => undefined }, + activeTargets: new Set(), +} + +/** Shell phase derived from Session lifecycle and registered target activity. */ +export type ConversationPhase = 'blank' | 'engaging' | 'active' + +/** + * Resolve the shell phase without adding Conversation data to the Session snapshot. + * @param session - current Session lifecycle state. + * @param conversation - current target-neutral Conversation state. + * @returns the phase used by the header, View ring, and composer layout. + */ +export function conversationPhase( + session: SessionSnapshot, + conversation: ConversationSnapshot, +): ConversationPhase { + const active = conversation.activeTargets.size > 0 + || (!session.blank && !session.awaitingFirstTurn) + || session.running + return active ? 'active' : session.promptAttempted ? 'engaging' : 'blank' +} diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index 1680e0322d..979680fe81 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -1,10 +1,4 @@ -/** Shared conversation view, selection, and store-state contracts. */ - -/** Tool call identity as carried on the wire (branded upstream in connection). */ -export type CallId = string - -/** Selection target for the details linkage channel (toolcall is the step special case). */ -export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string } +/** Conversation view and session-local presentation state. */ /** * One conversation view tab, projected from a 'conversation.view' slot @@ -12,21 +6,20 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C */ export interface ViewTab { id: string; label: string } -/** - * Per-session state shared by conversation, chat-view, and details slots. - * Unknown persisted view ids fall back to the stable Chat view. - */ -export interface ChatStoreState { - /** Details-linkage channel (conversation writes, details reads). */ - selection: SelectionTarget | null +/** One-shot focus request addressed to a Conversation View. */ +export interface ConversationViewRequest { + /** Target `conversation.view` entry id. */ + readonly view: string + /** Target-owned opaque focus identity. */ + readonly focus: string +} + +/** Per-session state owned by the target-neutral Conversation shell. */ +export interface ConversationStoreState { /** Composer draft (persisted; survives session switches and reloads). */ draft: string - /** Active conversation view id ('conversation.view' entry id); null falls back to Chat. */ + /** Preferred `conversation.view` entry id; null resolves to Chat when registered. */ view: string | null - /** - * One-shot inspect handoff: chat writes the call to reveal, the trajectory - * view consumes it and acknowledges by clearing. Read with `?? null` — - * persisted snapshots from before this field rehydrate without it. - */ - inspect: { callId: CallId } | null + /** Focus request consumed and acknowledged by the addressed View. */ + viewRequest: ConversationViewRequest | null } diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts similarity index 98% rename from packages/client/runtime/src/client/sessions/conversation-assembler.ts rename to packages/client/ui-conversation/src/client/conversation/assembler.ts index 85c59a4053..4fedf47572 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -8,7 +8,7 @@ import type { import { conversationContextKey } from '../contract/conversation.ts' import { ConversationLocationIndex, type ConversationLocationDataChange, -} from './conversation-location-index.ts' +} from './location-index.ts' interface Dependency { readonly kind: string @@ -41,6 +41,7 @@ interface PendingMatch { interface ViewState { readonly target: string readonly builder: ConversationViewBuilder + readonly isActive: ((snapshot: unknown) => boolean) | undefined snapshot: unknown } @@ -330,6 +331,18 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined } + /** + * Read targets whose owners classify their latest snapshot as visible activity. + * @returns active target ids. + */ + activeTargets(): ReadonlySet { + const active = new Set() + for (const view of this.views.values()) { + if (view.isActive?.(view.snapshot) === true) active.add(view.target) + } + return active + } + private sortedInputs(): ConversationEventInput[] { return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq) } @@ -779,6 +792,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.views.set(definition.target, { target: definition.target, builder, + isActive: definition.isActive === undefined + ? undefined + : snapshot => definition.isActive?.(snapshot) === true, snapshot: builder.empty, }) } @@ -800,9 +816,3 @@ function requireState( } return state } - -/** Structural registry pair accepted by Session and SessionManager. */ -export interface ConversationRuntime { - readonly events: ConversationEventDefinitions & { subscribe(listener: () => void): () => void } - readonly views: ConversationViewDefinitions & { subscribe(listener: () => void): () => void } -} diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts new file mode 100644 index 0000000000..cf0c4380b7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -0,0 +1,214 @@ +/** Per-Session target-neutral Conversation assembly. */ +import { Service, type Context } from '@deepseek-ai/cordis' +import type { + ISessions, SessionBinding, SessionEventSource, SessionEventWindow, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { + createSnapshotStore, type ObservableSnapshot, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' +import type { + ConversationEventInput, ConversationPublication, ConversationViewSnapshotMap, + ConversationViewSnapshotStore, +} from '../contract/conversation.ts' +import type { ConversationSnapshot } from '../contract/snapshot.ts' +import { ConversationNodeAssembler } from './assembler.ts' +import { ConversationEventRegistry } from './event-registry.ts' +import { ConversationViewRegistry } from './view-registry.ts' + +/** Observable faces published for one Session's Conversation assembly. */ +export interface ConversationBinding { + readonly snapshot: ObservableSnapshot + /** + * Resolve one target-owned snapshot source. + * @param target - registered Conversation target. + * @returns identity-stable source following the target. + */ + target>( + target: Target, + ): ObservableSnapshot +} + +class BoundConversation implements ConversationBinding { + readonly snapshot: SnapshotStore + private readonly viewStore: ConversationViewSnapshotStore + private readonly targetSources = new Map>() + private revision = -1 + private frame: number | undefined + private disposeFeed: () => void = () => {} + + constructor( + feed: SessionEventSource, + private readonly assembler: ConversationNodeAssembler, + ) { + this.viewStore = assembler + this.snapshot = createSnapshotStore(this.currentSnapshot()) + this.replace(feed.getSnapshot()) + this.disposeFeed = feed.subscribe(() => { + this.accept(feed.getSnapshot()) + }) + } + + target>( + target: Target, + ): ObservableSnapshot { + let source = this.targetSources.get(target) + if (source === undefined) { + const views = this.viewStore as unknown as { get(key: string): unknown } + source = { + getSnapshot: () => views.get(target), + subscribe: (listener) => { return this.snapshot.subscribe(listener) }, + } + this.targetSources.set(target, source) + } + return source as ObservableSnapshot + } + + rebuild(): void { this.publish(this.assembler.rebuildRegistry()) } + + dispose(): void { + if (this.frame !== undefined && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(this.frame) + } + this.frame = undefined + this.disposeFeed() + } + + private replace(window: SessionEventWindow): void { + this.revision = window.revision + this.publish(this.assembler.replaceWindow(window.entries.map(conversationInput), window.hasMore)) + } + + private accept(window: SessionEventWindow): void { + if (window.revision === this.revision) return + if (window.revision !== this.revision + 1 || window.change.kind === 'replace') { + this.replace(window) + return + } + this.revision = window.revision + switch (window.change.kind) { + case 'prepend': + this.publish(this.assembler.prepend(window.change.entries.map(conversationInput), window.hasMore)) + return + case 'append': { + let publication: ConversationPublication = 'none' + for (const entry of window.change.entries) { + const next = this.assembler.append(conversationInput(entry)) + if (next === 'immediate' || publication === 'none') publication = next + } + this.publish(publication) + } + } + } + + private publish(publication: ConversationPublication): void { + if (publication === 'none') return + if (publication === 'animation-frame' && typeof requestAnimationFrame === 'function') { + if (this.frame !== undefined) return + this.frame = requestAnimationFrame(() => { + this.frame = undefined + this.flush() + }) + return + } + this.flush() + } + + private flush(): void { + if (this.assembler.flush()) this.snapshot.set(this.currentSnapshot()) + } + + private currentSnapshot(): ConversationSnapshot { + return { + views: this.viewStore, + activeTargets: this.assembler.activeTargets(), + } + } +} + +function conversationInput(entry: SessionEventEntry): ConversationEventInput { + return { + event: entry.event as unknown as SessionEvent, + ...(entry.view === undefined ? {} : { view: entry.view }), + } +} + +interface BindingRecord { + readonly source: SessionBinding + readonly binding: BoundConversation + disposeScope: () => void +} + +/** Root service owning Conversation registries and per-Session bindings. */ +export class UiConversation extends Service { + /** Registry of event matchers and target snapshot builders. */ + readonly events: ConversationEventRegistry + /** Registry of target View definitions. */ + readonly views: ConversationViewRegistry + private readonly bindings = new Map() + + /** + * @param ctx - owning Client context. + * @param sessions - Session Controller object layer. + */ + constructor(ctx: Context, private readonly sessions: ISessions) { + super(ctx, 'uiConversation') + this.events = new ConversationEventRegistry(ctx) + this.views = new ConversationViewRegistry(ctx) + const rebuild = (): void => { + for (const record of this.bindings.values()) record.binding.rebuild() + } + let rebuildQueued = false + const scheduleRebuild = (): void => { + if (rebuildQueued) return + rebuildQueued = true + queueMicrotask(() => { + rebuildQueued = false + rebuild() + }) + } + ctx.effect(() => { + const disposeEvents = this.events.subscribe(scheduleRebuild) + const disposeViews = this.views.subscribe(scheduleRebuild) + return () => { + disposeViews() + disposeEvents() + for (const record of [...this.bindings.values()]) this.drop(record, true) + } + }, 'ui-conversation assembly') + } + + /** + * Resolve the Conversation binding for one Controller binding or Session id. + * @param source - Session binding or identity. + * @returns stable Conversation binding. + */ + binding(source: SessionBinding | SessionId): ConversationBinding { + const sessionId = typeof source === 'string' ? source : source.sessionId + const owner = typeof source === 'string' ? this.sessions.binding(source) : source + if (owner === undefined) throw new Error(`uiConversation.binding: unknown session "${sessionId}"`) + const current = this.bindings.get(owner.sessionId) + if (current?.source === owner) return current.binding + if (current !== undefined) this.drop(current, true) + const binding = new BoundConversation( + owner.eventSource, + new ConversationNodeAssembler(this.events, this.views), + ) + const record: BindingRecord = { source: owner, binding, disposeScope: () => {} } + this.bindings.set(owner.sessionId, record) + const disposeScope = owner.ctx.effect( + () => () => { this.drop(record, false) }, + 'ui-conversation binding', + ) + record.disposeScope = () => { void disposeScope() } + return binding + } + + private drop(record: BindingRecord, releaseScope: boolean): void { + if (this.bindings.get(record.source.sessionId) !== record) return + this.bindings.delete(record.source.sessionId) + record.binding.dispose() + if (releaseScope) record.disposeScope() + } +} diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts similarity index 92% rename from packages/client/runtime/src/client/sessions/assistant-timing.ts rename to packages/client/ui-conversation/src/client/conversation/assistant-timing.ts index 179f76281d..cf6e0d91e8 100644 --- a/packages/client/runtime/src/client/sessions/assistant-timing.ts +++ b/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts @@ -1,13 +1,13 @@ -// Shared assistant step-timing fold: Chat Definitions and the Trajectory +// Shared assistant step-timing fold: target Definitions and Trajectory // history fold derive AssistantTiming from the same step/start -> first token // delta -> assistant/message sequence. import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { AssistantTiming } from './conversation.ts' +import type { AssistantTiming } from '../contract/records.ts' // The first-token predicate lives beside the StreamChunk type in dsh-llm; -// re-exported here so Chat Definitions keep their client-runtime import. +// re-exported here for consumers sharing the Conversation timing fold. export { isTokenDelta } from '@deepseek-ai/dsh-llm/message' /** Pre-finalize timing boundaries for one assistant step (start + first token). */ diff --git a/packages/client/runtime/src/client/conversation/definition-registry.ts b/packages/client/ui-conversation/src/client/conversation/definition-registry.ts similarity index 81% rename from packages/client/runtime/src/client/conversation/definition-registry.ts rename to packages/client/ui-conversation/src/client/conversation/definition-registry.ts index 425f426512..c51722ce0b 100644 --- a/packages/client/runtime/src/client/conversation/definition-registry.ts +++ b/packages/client/ui-conversation/src/client/conversation/definition-registry.ts @@ -1,11 +1,19 @@ -import { Service } from '@deepseek-ai/cordis' +import { Service, type Context } from '@deepseek-ai/cordis' +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' /** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */ -export abstract class ConversationDefinitionRegistry extends Service { +export abstract class ConversationDefinitionRegistry { protected readonly definitions = new Map() private listeners = new Set<() => void>() private cached: readonly Definition[] = [] + /** @param ctx - Context whose effects own contributed Definitions. */ + constructor(protected readonly ctx: Context) { + Object.defineProperty(this, Service.tracker, { + value: { property: 'ctx' }, + }) + } + /** * Return reference-stable Definitions in registration order. * @returns current Definitions. @@ -55,6 +63,6 @@ export abstract class ConversationDefinitionRegistry extends Service /** Refresh cached entries and synchronously invalidate subscribers. */ protected refresh(): void { this.cached = [...this.definitions.values()] - for (const listener of this.listeners) listener() + notifySubscribers(this.listeners, '[ui-conversation] definition registry') } } diff --git a/packages/client/runtime/src/client/conversation/event-registry.ts b/packages/client/ui-conversation/src/client/conversation/event-registry.ts similarity index 84% rename from packages/client/runtime/src/client/conversation/event-registry.ts rename to packages/client/ui-conversation/src/client/conversation/event-registry.ts index d9eabda538..197cf4420a 100644 --- a/packages/client/runtime/src/client/conversation/event-registry.ts +++ b/packages/client/ui-conversation/src/client/conversation/event-registry.ts @@ -1,4 +1,3 @@ -import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition } from '../contract/conversation.ts' import { ConversationDefinitionRegistry } from './definition-registry.ts' @@ -6,11 +5,6 @@ import { ConversationDefinitionRegistry } from './definition-registry.ts' export class ConversationEventRegistry extends ConversationDefinitionRegistry { private fallback: ConversationNodeDefinition | undefined - /** @param ctx - owning Client Runtime context. */ - constructor(ctx: Context) { - super(ctx, 'conversationEvents') - } - /** * Register a uniquely named business Definition for the caller's lifetime. * @param definition - Definition contribution. @@ -22,7 +16,7 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry { + const dispose = this.ctx.effect(() => { this.fallback = definition this.refresh() return () => { @@ -45,7 +38,7 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry { void dispose() } } diff --git a/packages/client/runtime/src/client/sessions/failure-display.ts b/packages/client/ui-conversation/src/client/conversation/failure-display.ts similarity index 100% rename from packages/client/runtime/src/client/sessions/failure-display.ts rename to packages/client/ui-conversation/src/client/conversation/failure-display.ts diff --git a/packages/client/runtime/src/client/sessions/conversation-location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts similarity index 100% rename from packages/client/runtime/src/client/sessions/conversation-location-index.ts rename to packages/client/ui-conversation/src/client/conversation/location-index.ts diff --git a/packages/client/runtime/src/client/conversation/view-registry.ts b/packages/client/ui-conversation/src/client/conversation/view-registry.ts similarity index 74% rename from packages/client/runtime/src/client/conversation/view-registry.ts rename to packages/client/ui-conversation/src/client/conversation/view-registry.ts index 5372b4a4db..1abf07a5e3 100644 --- a/packages/client/runtime/src/client/conversation/view-registry.ts +++ b/packages/client/ui-conversation/src/client/conversation/view-registry.ts @@ -1,15 +1,9 @@ -import type { Context } from '@deepseek-ai/cordis' import type { ConversationViewDefinition } from '../contract/conversation.ts' import { ConversationDefinitionRegistry } from './definition-registry.ts' /** Runtime registry of per-target Conversation snapshot builders. */ export class ConversationViewRegistry extends ConversationDefinitionRegistry { - /** @param ctx - owning Client Runtime context. */ - constructor(ctx: Context) { - super(ctx, 'conversationViews') - } - /** * Register a uniquely named view builder factory for the caller's lifetime. * @param definition - target builder contribution. @@ -20,7 +14,7 @@ export class ConversationViewRegistry extends ConversationDefinitionRegistry - /** - * Drop one session's store. The session scope's disposer calls this; a - * blocker never needs to. - * @param sessionId - the session being torn down. - */ - forget(sessionId: SessionId): void -} +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ComposerBlock, ComposerBlocks } from '../contract/composer-blocks.ts' /** The per-session composer-block registry (one instance per plugin fiber). */ export class ComposerBlockRegistry implements ComposerBlocks { diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index cc42d0c1ad..6d48be9371 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -6,16 +6,16 @@ * sink). Package-private; the hub alone constructs it and wires the scoped * event listeners onto it. */ -import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from '@deepseek-ai/cordis' +import { + createSnapshotStore, type ObservableSnapshot, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' import type { - ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, InputTriggerController, SubmitImageAttachment, SubmitOutcome, TokenSpan, -} from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { - DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, - PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, -} from './contract.ts' + ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, + EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, + InputTriggerController, PasteComponent, PickOutcome, QueuedMessage, ReferenceInsert, + SessionInput, SubmitAttempt, SubmitImageAttachment, SubmitOutcome, TokenSpan, +} from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import { InputMachine, projectClipboard } from './machine.ts' @@ -32,7 +32,7 @@ export interface PopupDismissFace { */ export interface SessionInputDeps { /** Session-scope ctx handed to claim.submit transactions. */ - actx: ClientContext + actx: Context /** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */ inputTriggers?: (() => InputTriggerController | undefined) | undefined /** PopupSelect shell face resolver (dismissal on submit lock / escape). */ @@ -103,7 +103,7 @@ export class SessionInputShell implements SessionInput { /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ private imageSendInFlight = false private disposed = false - /** Draft persistence mirror (chat store write; receives the clipboard projection, never display-only ranges). */ + /** Draft persistence mirror (Conversation store write; receives the clipboard projection, never display-only ranges). */ private mirrorFn: ((text: string) => void) | undefined constructor(private readonly deps: SessionInputDeps) { @@ -401,7 +401,7 @@ export class SessionInputShell implements SessionInput { } /** - * Bind the draft persistence mirror (chat store write). Adopt-on-bind: the + * Bind the draft persistence mirror (Conversation store write). Adopt-on-bind: the * store draft may hold a persisted value from a previous mount; the caller * seeds it via setDraft BEFORE binding, and afterwards every machine-adopted * draft mirrors out. diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 7e3b95a314..9e53281302 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -1,25 +1,36 @@ /** * InputHub: the SessionInputResolver implementation (`ctx.conversation.input`) — one - * SessionInputShell per session, created inside the sessions provide + * SessionInputShell per session, created inside the uiSession provide * materialization (the 'input' standard-kit entry IS the * creation trigger) and torn down by the scope disposer (instance-and-scope - * share one lifecycle). The hub registers the three scoped input-mutation - * listeners on each session's actx (the sole consumer side of the ui-input-trigger - * bail events) and owns the default-sink choreography: every session is a + * share one lifecycle). The hub registers the scoped input-mutation + * listeners on each Session context and owns the default-sink choreography: every session is a * real host entity, so the sink is one unconditional prompt path. */ -import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { InputTriggerController, SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { Context } from '@deepseek-ai/cordis' +import type { + ISessions, SessionBinding, SessionFace, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' -import { queueReadFaceOf } from '../queue/store.ts' -import type { ComposerKeyboard, DraftAttachmentId, SessionInputResolver, SessionInput } from './contract.ts' +import { queueReadFaceOf } from './queue-store.ts' +import type { + ComposerKeyboard, DraftAttachmentId, InputTriggerController, SessionInputResolver, SessionInput, + SubmitImageAttachment, SubmitOutcome, +} from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' /** Structural command face for per-session popup resolution. */ interface CommandFace { - popupFor(actx: ClientContext): PopupDismissFace + popupFor(actx: Context): PopupDismissFace +} + +/** Optional input-trigger service resolved without importing its implementation. */ +interface InputTriggerServiceFace { + /** @param actx - Session scope. @returns that Session's trigger provider. */ + sessionOf(actx: Context): InputTriggerController } /** Attachment-send face resolved lazily to keep hub/service construction acyclic. */ @@ -44,7 +55,7 @@ export class InputHub implements SessionInputResolver { * @param t - conversation-namespace translate thunk (reads the active locale at call time). */ constructor( - private readonly rootCtx: ClientContext, + private readonly rootCtx: Context, private readonly t: TranslateNS<'conversation'>, ) {} @@ -53,7 +64,7 @@ export class InputHub implements SessionInputResolver { * @param actx - session-scope context. * @returns the resident per-session facade. */ - for(actx: ClientContext): SessionInput { + for(actx: Context): SessionInput { const sessions = this.sessions() const id = sessions.scopeOf(actx) if (id === undefined) throw new Error('conversation.input.for requires a session scope') @@ -149,7 +160,7 @@ export class InputHub implements SessionInputResolver { * Resolve the optional slash controller for composer chrome that launches * the shared candidate menu without typing a trigger. * @param id - session id. - * @returns the resident controller, or undefined when ui-input-trigger is absent. + * @returns the resident controller, or undefined when no trigger provider is installed. */ inputTriggers(id: SessionId): InputTriggerController | undefined { const actx = this.sessions().scope(id) @@ -197,12 +208,12 @@ export class InputHub implements SessionInputResolver { } } - private controller(actx: ClientContext): InputTriggerController | undefined { - const inputTriggers = this.rootCtx.get('inputTriggers') + private controller(actx: Context): InputTriggerController | undefined { + const inputTriggers = this.rootCtx.get('inputTriggers') as InputTriggerServiceFace | undefined return inputTriggers?.sessionOf(actx) } - private popup(actx: ClientContext): PopupDismissFace | undefined { + private popup(actx: Context): PopupDismissFace | undefined { const command = this.rootCtx.get('commandUi') as CommandFace | undefined return command?.popupFor(actx) } diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index a1a49004a4..75ae348d8f 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -13,12 +13,12 @@ * as a draftRev advance (begin-command / insert-ref / consume-token / * paste-upgrade all answer their bail events this way). */ -import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { - ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions, - InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt, -} from './contract.ts' + CommandClaim, ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, + InputMachineOptions, InputState, Occurrence, PasteAttemptState, PasteComponent, + ReferenceInsert, SubmitAttempt, TokenSpan, +} from '../contract/input.ts' /** Legacy fixed-width object replacement character rejected from pasted text. */ export const PLACEHOLDER = '' diff --git a/packages/client/ui-conversation/src/client/queue/store.ts b/packages/client/ui-conversation/src/client/input/queue-store.ts similarity index 76% rename from packages/client/ui-conversation/src/client/queue/store.ts rename to packages/client/ui-conversation/src/client/input/queue-store.ts index ca94348b88..09b10da0d0 100644 --- a/packages/client/ui-conversation/src/client/queue/store.ts +++ b/packages/client/ui-conversation/src/client/input/queue-store.ts @@ -1,12 +1,13 @@ /** * Queue read face for the InputState.queue projection (frozen contract in - * ../input/contract.ts): a uSES-compatible observable over one session's + * ../contract/input.ts): a uSES-compatible observable over one session's * transient inbox rows. The Session snapshot already keeps the queue array * reference-stable across unrelated snapshot swaps, so this is a pure * projection — no second store, no copy. */ -import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' -import type { QueuedMessage } from '../input/contract.ts' +import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { QueuedMessage } from '../contract/input.ts' /** * Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot). diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts index 27b1cff326..0b7f0ad4df 100644 --- a/packages/client/ui-conversation/src/client/input/submission-policy.ts +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -4,8 +4,9 @@ * Host and Agent keep the actual delivery-window authority. */ import { - createSnapshotStore, type SettingsScope, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import type { BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, } from '../contract/composer-submission.ts' diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f4e7a7c59a..e151ebec75 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -3,14 +3,12 @@ /** Dictionary namespace owned by this plugin. */ export const NS = 'conversation' -// The claimed /plan hint and the plan-mode textarea placeholder share one -// string: both describe the same next action. +// The claimed /plan hint and the plan-mode textarea placeholder describe the same next action. const PLAN_NEXT_ACTION_ZH = '描述你的任务以生成计划' const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'view.chat': '对话', 'hint.plan': PLAN_NEXT_ACTION_ZH, 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', @@ -20,10 +18,10 @@ export const zh = { 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容', 'placeholder.workspace': '选择一个工作区开始', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', - 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', 'image.dropTitle': '图片拖动到此处即可添加', 'image.dropDesc': '最多 {count} 张,每张 {size}', @@ -40,7 +38,6 @@ export const zh = { 'image.loading': '图片加载中…', 'image.preview': '原图预览', 'image.closePreview': '关闭原图预览', - 'image.serviceUnavailable': '图片读取服务不可用', 'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片', 'image.tooMany': '一条消息最多添加 {count} 张图片', 'image.fileTooLarge': '单张图片不能超过 {size}', @@ -55,13 +52,6 @@ export const zh = { 'context.system': '系统提示词', 'context.tools': '工具', 'context.messages': '对话消息', - 'stats.counts': '{turns} 轮 · {steps} 步', - 'stats.llm': 'LLM {duration}', - 'stats.toolCall': '工具调用 {duration}', - 'stats.ttftAverage': '首 token 平均 {duration}', - 'stats.tokensPerSecond': '{throughput} tok/s', - 'stats.cacheHit': '缓存命中 {percent}%', - 'stats.tokens': '输入 {input} tok · 输出 {output} tok', 'settings.enter.title': '繁忙时 Enter 键行为', 'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为', 'settings.enter.queue': '排队发送', @@ -75,77 +65,13 @@ export const zh = { 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', - 'details.title': '详情', - 'details.close': '关闭详情', - 'details.empty': '点击消息流中的工具行查看详情', - 'details.notInWindow': '该调用不在当前窗口内', - 'details.input': '输入', - 'details.output': '输出', - 'details.running': '运行中…', 'todo.title': '任务', 'todo.progress.done': '{done} 已完成', 'todo.progress.active': '{active} 进行中', 'todo.progress.pending': '{pending} 待处理', 'todo.rowTitle': '更新任务清单', 'todo.completed': '{done}/{total} 已完成', - 'chat.loadingHistory': '载入历史…', - 'chat.loadError': '历史加载失败:{message}({code})', - 'chat.loadOlder': '加载更早', - 'chat.toBottom': '回到底部', - 'fileOpen.title': '无法打开文件', - 'fileOpen.unknown': '无法打开此文件', - 'fileOpen.folderTitle': '无法打开文件夹', - 'fileOpen.folderUnknown': '无法打开此文件夹', - 'message.extraBlock': '附加内容块', - 'message.contextInjection': '上下文注入', - 'message.contextRecall': '跨会话召回', - 'message.referenceSummary': '引用会话 · {labels}', - 'message.referenceSeparator': '、', - 'message.context.instructions.loaded': '已载入', - 'message.context.instructions.added': '已新增', - 'message.context.instructions.updated': '已更新', - 'message.context.instructions.removed': '已移除', - 'message.context.catalog.replaced': '替换目录', - 'message.context.catalog.more': '…还有 {count} 条', - 'message.context.snapshot.supersedes': '取代先前的快照', - 'message.context.relay.from': '来自会话 {session}', - 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', - 'message.context.recall.truncated': '已截断', - 'message.compaction': '上下文已压缩', - 'message.compaction.running': '正在压缩…', - 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', - 'message.compaction.expand': '点击查看压缩摘要', - 'message.compaction.unavailable': '压缩摘要不可用', - 'message.unknownSurface': '未知 surface 事件:{type}', - 'message.unknownBlock': '未知内容块', - 'message.stopped': '已停止', - 'message.branch': '在新对话中分支', - 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', - 'message.retry.active': '正在重试模型请求', - 'message.retry.cancelled': '模型请求重试已取消', - 'message.retry.started': '已重试模型请求', - 'message.retry.scheduled': '等待重试模型请求', - 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s', - 'message.retry.delay': '重试延迟:', - 'message.retry.failure': '失败原因:', - 'message.turnError': '本轮运行失败', - 'message.maxTokens': '已达到输出 token 上限', - 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', - 'message.ranFor': '用时 {duration}', - 'message.ttft': '首 token {seconds}秒', - 'message.tokensPerSecond': '{tps} tok/s', - 'duration.seconds': '{seconds}秒', - 'duration.minutes': '{minutes}分{seconds}秒', - 'command.running': '执行中…', - 'command.failed': '命令失败', - 'command.done': '已完成', - 'command.title': '命令', 'command.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', - 'approval.waiting': '等待审批', - 'approval.detail.aria': '审批详情', - 'approval.escalation': '工具 {toolName} 请求越权执行', - 'approval.reject': '拒绝', - 'approval.allowOnce': '允许一次', 'ask.rowTitle': '提问', 'ask.waiting': '等待回答', 'ask.cancelled': '已取消', @@ -157,6 +83,7 @@ export const zh = { 'row.running': '运行中', 'row.failed': '失败', 'row.stopped': '已停止', + 'details.running': '运行中…', 'queue.count': '{n} 条排队消息', 'queue.edit': '编辑排队消息', 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', @@ -177,9 +104,6 @@ export const zh = { 'terminal.collapseAria': '收起输出', 'terminal.expandAria': '展开其余 {n} 行输出', 'terminal.expandRest': '… 其余 {n} 行', - 'json.truncated': '… 已截断,共 {total} 字符', - 'clock.md': '{m}月{d}日', - 'clock.ymd': '{y}年{m}月{d}日', } satisfies Record /** The conversation namespace key union. */ @@ -187,7 +111,6 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - 'view.chat': 'Chat', 'hint.plan': PLAN_NEXT_ACTION_EN, 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', @@ -197,10 +120,10 @@ export const en = { 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build', 'placeholder.workspace': 'Choose a workspace to start', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', - 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', 'image.dropTitle': 'Drag images here to add them', 'image.dropDesc': 'Up to {count} images, {size} each', @@ -217,7 +140,6 @@ export const en = { 'image.loading': 'Loading image…', 'image.preview': 'Original image preview', 'image.closePreview': 'Close original image preview', - 'image.serviceUnavailable': 'Image loading service unavailable', 'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported', 'image.tooMany': 'A message can include up to {count} images', 'image.fileTooLarge': 'Each image must be smaller than {size}', @@ -232,13 +154,6 @@ export const en = { 'context.system': 'System prompt', 'context.tools': 'Tools', 'context.messages': 'Messages', - 'stats.counts': '{turns} turns · {steps} steps', - 'stats.llm': 'LLM {duration}', - 'stats.toolCall': 'Tool call {duration}', - 'stats.ttftAverage': 'TTFT avg {duration}', - 'stats.tokensPerSecond': '{throughput} tok/s', - 'stats.cacheHit': 'Cache hit {percent}%', - 'stats.tokens': 'Input {input} tok · Output {output} tok', 'settings.enter.title': 'Enter behavior while busy', 'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior', 'settings.enter.queue': 'Queue', @@ -252,77 +167,13 @@ export const en = { 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', - 'details.title': 'Details', - 'details.close': 'Close details', - 'details.empty': 'Click a tool row in the message flow to view its details', - 'details.notInWindow': 'This call is outside the current window', - 'details.input': 'Input', - 'details.output': 'Output', - 'details.running': 'Running…', 'todo.title': 'To-dos', 'todo.progress.done': '{done} completed', 'todo.progress.active': '{active} in progress', 'todo.progress.pending': '{pending} pending', 'todo.rowTitle': 'Update to-do list', 'todo.completed': '{done}/{total} completed', - 'chat.loadingHistory': 'Loading history…', - 'chat.loadError': 'Failed to load history: {message} ({code})', - 'chat.loadOlder': 'Load earlier', - 'chat.toBottom': 'Back to bottom', - 'fileOpen.title': 'Couldn’t open file', - 'fileOpen.unknown': 'Couldn’t open this file', - 'fileOpen.folderTitle': 'Couldn’t open folder', - 'fileOpen.folderUnknown': 'Couldn’t open this folder', - 'message.extraBlock': 'Extra content block', - 'message.contextInjection': 'Context injection', - 'message.contextRecall': 'Session recall', - 'message.referenceSummary': 'Referenced session · {labels}', - 'message.referenceSeparator': ', ', - 'message.context.instructions.loaded': 'loaded', - 'message.context.instructions.added': 'added', - 'message.context.instructions.updated': 'updated', - 'message.context.instructions.removed': 'removed', - 'message.context.catalog.replaced': 'Replacement catalog', - 'message.context.catalog.more': '… {count} more', - 'message.context.snapshot.supersedes': 'Supersedes earlier snapshots', - 'message.context.relay.from': 'From session {session}', - 'message.context.recall.counts': '{retained} kept · {omitted} omitted', - 'message.context.recall.truncated': 'truncated', - 'message.compaction': 'Context compacted', - 'message.compaction.running': 'Compacting context…', - 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', - 'message.compaction.expand': 'View compaction summary', - 'message.compaction.unavailable': 'Compaction summary unavailable', - 'message.unknownSurface': 'Unknown surface event: {type}', - 'message.unknownBlock': 'Unknown content block', - 'message.stopped': 'Stopped', - 'message.branch': 'Branch into a new conversation', - 'message.branchUnavailable': 'Available only on the last message of a completed turn', - 'message.retry.active': 'Retrying model request', - 'message.retry.cancelled': 'Model request retry cancelled', - 'message.retry.started': 'Retried model request', - 'message.retry.scheduled': 'Waiting to retry model request', - 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', - 'message.retry.delay': 'Retry delay: ', - 'message.retry.failure': 'Failure reason: ', - 'message.turnError': 'This turn failed', - 'message.maxTokens': 'Output token limit reached', - 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', - 'message.ranFor': 'Ran for {duration}', - 'message.ttft': 'TTFT {seconds}s', - 'message.tokensPerSecond': '{tps} tok/s', - 'duration.seconds': '{seconds}s', - 'duration.minutes': '{minutes}m {seconds}s', - 'command.running': 'Running…', - 'command.failed': 'Command failed', - 'command.done': 'Completed', - 'command.title': 'Command', 'command.imagesUnsupported': '/{command} does not accept image attachments; remove them first', - 'approval.waiting': 'Waiting for approval', - 'approval.detail.aria': 'Approval details', - 'approval.escalation': 'Tool {toolName} requests privileged execution', - 'approval.reject': 'Reject', - 'approval.allowOnce': 'Allow once', 'ask.rowTitle': 'Ask question', 'ask.waiting': 'waiting', 'ask.cancelled': 'cancelled', @@ -334,6 +185,7 @@ export const en = { 'row.running': 'Running', 'row.failed': 'Failed', 'row.stopped': 'Stopped', + 'details.running': 'Running…', 'queue.count': '{n} queued messages', 'queue.edit': 'Edit queued message', 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', @@ -354,7 +206,4 @@ export const en = { 'terminal.collapseAria': 'Collapse output', 'terminal.expandAria': 'Expand the remaining {n} output lines', 'terminal.expandRest': '… {n} more lines', - 'json.truncated': '… truncated, {total} characters total', - 'clock.md': '{m}/{d}', - 'clock.ymd': '{y}-{m}-{d}', } satisfies Record diff --git a/packages/client/ui-conversation/src/client/pending-composer.ts b/packages/client/ui-conversation/src/client/pending-composer.ts new file mode 100644 index 0000000000..bb8be56766 --- /dev/null +++ b/packages/client/ui-conversation/src/client/pending-composer.ts @@ -0,0 +1,18 @@ +/** Shared settlement mechanics for composer takeovers backed by a pending waterfall. */ + +/** + * Run one pending composer settlement and preserve non-Error rejection causes. + * @param settle - synchronous Promise resolver or rejector invocation. + * @param failureMessage - message used when the resolver throws a non-Error value. + * @returns completion or a rejection carrying the original failure. + */ +export function settlePendingComposer(settle: () => void, failureMessage: string): Promise { + try { + settle() + return Promise.resolve() + } catch (error) { + return Promise.reject(error instanceof Error + ? error + : new Error(failureMessage, { cause: error })) + } +} diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index a78ba74689..5948cae9a9 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,7 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import { useEffect, useId, useMemo, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, Tooltip, diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 866ae53b61..24c0303b6a 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,14 +13,17 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. -import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' -import type { ComposerBlocks } from './input/blocks.ts' -import type { DraftAttachmentId, SessionInputResolver } from './input/contract.ts' +import type { ComposerBlocks } from './contract/composer-blocks.ts' +import type { + DraftAttachmentId, SessionInputResolver, SubmitImageAttachment, SubmitOutcome, +} from './contract/input.ts' import type { InputSubmitMode } from './contract/composer-submission.ts' +import { bytesToBase64 } from './browser-bytes.ts' /** * The outward conversation face (`ctx.conversation`): the scope-addressed @@ -70,12 +73,6 @@ function browserDraftAttachment(file: File): ComposerAttachment { } } -interface ImageUrlEntry { - readonly sessionId: SessionId - readonly generation: number - readonly pending: Promise -} - /** Unsupported browser-declared image type, localized by the UI boundary. */ export class UnsupportedImageMediaTypeError extends Error { /** Browser-declared MIME value, possibly empty. */ @@ -96,10 +93,6 @@ export class ConversationController extends Service implements IConversation { /** The per-session composer-block registry. */ readonly blocks: ComposerBlocks private readonly draftAttachments = new Map() - private readonly imageUrls = new Map() - private readonly imageGenerations = new Map() - private readonly createdImageUrls = new Set() - private disposed = false /** * @param ctx - owning root context (the plugin apply context; the service @@ -113,13 +106,11 @@ export class ConversationController extends Service implements IConversation { this.input = config.input this.blocks = config.blocks ctx.effect(() => () => { - this.disposed = true - for (const url of this.createdImageUrls) revokePreview(url) - this.createdImageUrls.clear() + for (const attachment of this.draftAttachments.values()) { + revokePreview(attachment.previewUrl) + } this.draftAttachments.clear() - this.imageUrls.clear() - this.imageGenerations.clear() - }, 'conversation attachment URL cache') + }, 'conversation draft attachments') } /** @@ -172,7 +163,6 @@ export class ConversationController extends Service implements IConversation { return files.map((file) => { const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) - this.createdImageUrls.add(attachment.previewUrl) return attachment }) } @@ -214,7 +204,6 @@ export class ConversationController extends Service implements IConversation { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return this.draftAttachments.delete(id) - this.createdImageUrls.delete(attachment.previewUrl) revokePreview(attachment.previewUrl) } @@ -226,63 +215,6 @@ export class ConversationController extends Service implements IConversation { for (const attachment of attachments) this.releaseDraftImage(attachment.id) } - /** - * Resolve and cache one session-authorized historical image URL. - * @param sessionId - owning session authorization scope. - * @param attachment - durable image reference. - * @returns browser URL valid until its rendered session is released. - */ - resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { - if (this.disposed) return Promise.reject(new Error('conversation.resolveImage: service is disposed')) - const key = `${sessionId}:${attachment.attachmentId}` - const cached = this.imageUrls.get(key) - if (cached !== undefined) return cached.pending - const generation = this.imageGenerations.get(sessionId) ?? 0 - const session = this.requireSessions().binding(sessionId)?.session - if (session === undefined) { - return Promise.reject(new Error(`conversation.resolveImage: unknown session "${sessionId}"`)) - } - const pending = session.readAttachment(attachment.attachmentId) - .then((result) => { - if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) - if (this.disposed) throw new Error('conversation.resolveImage: service was disposed before loading completed') - if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) { - throw new Error('historical image scope was released before loading completed') - } - if (typeof URL.createObjectURL !== 'function') { - return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` - } - const bytes = Uint8Array.from(result.value.data) - const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) - this.createdImageUrls.add(url) - return url - }) - .catch((error: unknown) => { - if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key) - throw error - }) - this.imageUrls.set(key, { sessionId, generation, pending }) - return pending - } - - /** - * Release every historical image URL owned by one rendered session. - * @param sessionId - rendered session scope. - */ - releaseSessionImages(sessionId: SessionId): void { - this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1) - for (const [key, entry] of this.imageUrls) { - if (entry.sessionId !== sessionId) continue - this.imageUrls.delete(key) - void entry.pending.then((url) => { - if (!this.createdImageUrls.delete(url)) return - revokePreview(url) - }, () => { - // A failed or invalidated load owns no object URL. - }) - } - } - /** Apply one operation to a pending queue occurrence. */ async updateQueue(itemId: QueueItemId, action: QueueAction): Promise { const session = this.scopedSession('updateQueue') @@ -360,15 +292,6 @@ function imageMediaType(value: string): ImageMediaType { } } -function bytesToBase64(data: Uint8Array): string { - let binary = '' - const chunk = 0x8000 - for (let offset = 0; offset < data.length; offset += chunk) { - binary += String.fromCharCode(...data.subarray(offset, offset + chunk)) - } - return btoa(binary) -} - function revokePreview(url: string): void { if (url.startsWith('blob:')) URL.revokeObjectURL(url) } diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx index 2b5552c3cc..07ebd615b3 100644 --- a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx @@ -1,6 +1,6 @@ /** General Settings row for the Composer's busy-state Enter preference. */ import { useState } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { BusyEnterBehavior } from '../contract/composer-submission.ts' diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css deleted file mode 100644 index 3c6cc58187..0000000000 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css +++ /dev/null @@ -1,97 +0,0 @@ -.root { - display: flex; - flex-direction: column; - align-items: center; - /* Sides = clearance + 16px so the card lands on the shared content width - (input card - 32) at every viewport. */ - padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px; -} - -.card { - overflow: hidden; - width: 100%; - max-width: var(--dsh-chat-content-width); - border: 1px solid var(--dsw-alias-state-warn-secondary); - border-radius: 20px; - background: var(--dsw-specific-input-major); - box-shadow: var(--dsw-shadow-lv2); - /* Elevated surface in dark, same as the menus: `.body` inside scrolls once - the justification or command passes the cap, so the thumb takes the l2 - pair. Declared on the card because the elevation belongs to the surface, - and the custom properties inherit down to the region that actually - scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */ - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); -} - -/* Tinted full-width header band. */ -.strip { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 16px; - background: var(--dsw-alias-state-warn-tertiary); - color: var(--dsw-alias-state-warn-primary); - font-size: 13px; - line-height: 18px; -} - -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--dsw-alias-state-warn-primary); -} - -/* Scroll region: an agent's justification and its command are unbounded model - text (a one-line `cd` or a 40-line heredoc), and the seat sits in a - fixed-height column — uncapped, a long command pushed the action row past - the viewport and the approval could not be answered at all. The strip and - the action row stay outside, so the buttons are always on screen. */ -.body { - display: flex; - flex-direction: column; - gap: 6px; - /* border-box so the cap is the region's OUTER height: the composer's draft - area counts its padding inside the same number, and the two seats are - only interchangeable if they occupy the same box. */ - box-sizing: border-box; - max-height: var(--dsh-composer-text-max-height); - overflow-y: auto; - padding: 12px 16px 0; -} - -/* The model's justification is the panel's message, not a footnote. */ -.headline { - color: var(--dsw-alias-label-primary); - font-size: 15px; - font-weight: 500; - line-height: 24px; -} - -.command { - color: var(--dsw-alias-label-tertiary); - font-family: var(--ds-font-family-code); - font-size: 13px; - line-height: 20px; - word-break: break-all; -} - -/* Card-level row, not body content. Its padding reproduces the metrics the row - had inside the body: 14px above (the flex gap of 6 plus the row's 8px top - margin, neither of which reaches it out here) and the body's former 14px - bottom pad below, so the resting card is unchanged. Buttons are the shared - outline/primary capsules (Button atom, matching QuestionComposer's footer); - only the reject's danger hover is local. */ -.actionRow { - display: flex; - justify-content: flex-end; - gap: 8px; - padding: 14px 16px 14px; -} - -.reject:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); - border-color: transparent; -} diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx deleted file mode 100644 index 32f7e64b92..0000000000 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useMemo, useState } from 'react' -import { Button } from '@deepseek-ai/dsh-client-ui-primitives' -import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' -import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' -import { rootToolCall } from '../chat/tool-node-reader.ts' -import css from './ApprovalPanel.module.css' - -/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */ -export function commandOf(call: RunningToolCall | undefined): string | undefined { - if (call === undefined) return undefined - try { - const args = JSON.parse(call.argsRaw) as Record - return typeof args.command === 'string' ? args.command : undefined - } catch { - return undefined - } -} - -/** - * Render one pending approval and remount local answer state per request. - * @param props - the selector-matched pending approval carrier plus the framework standard kit. - * @returns The approval prompt for this request. - */ -export function ApprovalPanel(props: ApprovalComposerProps) { - const approval = useMemo(() => new PendingApproval(props.matched), [props.matched]) - const command = props.useSession((snapshot) => { - if (approval.callId === undefined) return undefined - const root = rootToolCall(snapshot, approval.callId) - if (root === undefined) return undefined - return root.callId === approval.callId && !('kind' in root) ? commandOf(root) : undefined - }) - return -} - -function ApprovalFlow({ pending, command, t }: { - pending: PendingApproval - command?: string - t: ApprovalComposerProps['t'] -}) { - // Keep actions disabled until the resolved frame arrives; failed answers - // re-enable them for retry. - const [answered, setAnswered] = useState(false) - const answer = (outcome: 'allowed-once' | 'rejected'): void => { - setAnswered(true) - void pending.answer(outcome).catch(() => { setAnswered(false) }) - } - return ( -
-
-
{t('approval.waiting')}
- {/* Tab stop: the region scrolls once the command passes the cap and - holds nothing focusable of its own, so without one a keyboard-only - user cannot reach the command's tail before answering. */} -
-
{pending.reason ?? t('approval.escalation', { toolName: pending.toolName })}
- {command !== undefined &&
{command}
} -
-
- - -
-
-
- ) -} diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx index b6b62468d1..06c063c91a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx @@ -5,12 +5,12 @@ * capacity. */ import { useEffect, useRef, useState } from 'react' -import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client' +import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: the `contextPressure` / `contextBreakdown` projection key merges. import type {} from '@deepseek-ai/dsh-token-meter/client' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerBarProps } from '../contract/slots.ts' -import { contextOccupancy, formatTokens } from '../chat/StatsLine.tsx' +import { contextOccupancy } from '../context-occupancy.ts' import css from './ContextMeter.module.css' /** Ring geometry: 14px viewBox, 2px stroke. */ @@ -31,6 +31,20 @@ const ROWS = [ { key: 'messageTokens', label: 'context.messages', color: css.colorMessages }, ] as const +/** + * Format a token count for the compact context panel. + * @param value - token count. + * @returns compact count using K or M when needed. + */ +function formatTokens(value: number): string { + const scaled = (candidate: number): string => candidate >= 100 + ? String(Math.round(candidate)) + : String(Math.round(candidate * 10) / 10) + if (value < 1_000) return String(value) + if (value < 1_000_000) return `${scaled(value / 1_000)}K` + return `${scaled(value / 1_000_000)}M` +} + export interface ContextMeterProps { useProjection: UseProjection /** The owning bar's locale seat, passed down as a plain prop. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 588f93d057..6357c8cb8b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -7,8 +7,8 @@ /* Shared width axis for the whole column: one content width W (--dsh-chat-content-width) for the transcript, the dock cards - (todo/goal/queue: card minus four insets, 4 x 8 = 32), and the takeover - cards (question/approval/plan review); the input card alone is W + 32px. + (todo/goal/queue: card minus four insets, 4 x 8 = 32), and business-owned + takeover cards; the input card alone is W + 32px. The relation also holds when a narrow viewport shrinks everything: the chat scroller and the takeover frames pad clearance + 16px per side while the input card clears the bare clearance, so the input card stays exactly diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 07655d7dba..458c15e2e9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -4,8 +4,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' -import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' +import { conversationPhase } from '../contract/snapshot.ts' import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import css from './ConversationRoot.module.css' @@ -13,13 +14,18 @@ import css from './ConversationRoot.module.css' export type ConversationRootProps = ConversationSlotProps export function ConversationRoot({ - sessionId, useSession, useSessions, useWorkspaces, useInput, useComposerBlock, + sessionId, useSession, useSessions, useSessionPendingInteraction, + useWorkspaces, useConversation, useInput, useComposerBlock, renderSlot, renderSlotChain, selectWorkspace, t, }: ConversationRootProps) { - const openState = useSession(s => s.openState) - const composerPhase = useSession(s => s.composerPhase) - const pending = useSession(s => s.pending) ?? [] const session = useSession(s => s) + const pendingInteraction = useSessionPendingInteraction(snapshot => + sessionId === undefined ? undefined : snapshot.get(sessionId)) + const conversation = useConversation(s => s) + const shellPhase = session === undefined || conversation === undefined + ? 'blank' + : conversationPhase(session, conversation) + const openState = session?.openState const inputState = useInput(s => s) const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank) @@ -33,7 +39,7 @@ export function ConversationRoot({ const pickerAnchor = useRef(null) // Publishes the seat's live height as --dsh-composer-height on the scroll - // body so floating controls (ChatView back-to-bottom) clear the composer as + // body so floating View controls clear the composer as // it grows. Callback ref, not an effect; stable identity prevents observer // churn while the first blank session fills the resident body outlet. const seatObserver = useRef(null) @@ -74,10 +80,10 @@ export function ConversationRoot({ // The exemption is deliberately open-state-wide, not loading-only: a // summary-blank session is the hero before its open starts (`cold`) and // after one fails (`error`) for the same reason — there is no history. - const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading' + const settling = sessionId !== undefined && shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true const hero = sessionId === undefined - || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true)) + || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true)) const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } @@ -148,11 +154,10 @@ export function ConversationRoot({ // user clears it. ? { blocked: composerBlock, placeholder: composerBlock.reason } : hero ? { placeholder: t('placeholder.hero') } : {}), - overlay: renderSlot('conversation.input.overlay', {}), + overlay: sessionId === undefined ? undefined : renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), - // Stats band under the card, inside the bar's width column so both - // share one constraint (composer.dock = stats-line family). + // Ambient dock under the card shares the composer's width constraint. footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null, }) @@ -169,13 +174,13 @@ export function ConversationRoot({ const phase = settling ? 'settling' : hero ? 'hero' : 'active' const composer = renderSlotChain( 'conversation.composer', - { interactions: pending, session }, - { fallback: composerBar, overlay: true }, + { sessionId, session, pendingInteraction }, + { fallback: composerBar, fallbackOnly: sessionId === undefined, overlay: true }, ) // Sticky wraps the whole chain output (fallback + elected overlay), not // only `.composerStack`: overlay:true renders those as siblings, and sticky - // on the fallback alone would leave Question/Approval panels at the content + // on the fallback alone would leave a business-owned takeover at the content // end off-screen when the user is not pinned to the floor. const composerSeat = (
@@ -185,9 +190,9 @@ export function ConversationRoot({ return (
- {renderSlot('conversation.session.header', {})} + {sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
- {renderSlot('conversation.session', {})} + {sessionId === undefined ? null : renderSlot('conversation.session', {})} {composerSeat}
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index c726bcc753..ce989f076e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,11 +1,13 @@ /** Strict per-session header/body content inserted into the resident conversation layout. */ -import { useEffect, useSyncExternalStore } from 'react' +import { useEffect } from 'react' import clsx from 'clsx' -import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, } from '../contract/slots.ts' +import { conversationPhase } from '../contract/snapshot.ts' import type { ViewTab } from '../contract/views.ts' import css from './ConversationRoot.module.css' @@ -23,11 +25,10 @@ interface Breadcrumb { const DEFAULT_VIEW_ID = 'chat' -/** Resolve by id and keep stale persisted selections on the stable Chat fallback. */ +/** Resolve a persisted selection, then registered Chat, without choosing another View. */ function resolveActiveView(tabs: readonly ViewTab[], selectedId: string | null): ViewTab | undefined { - const requestedId = selectedId ?? DEFAULT_VIEW_ID - return tabs.find(view => view.id === requestedId) - ?? tabs.find(view => view.id === DEFAULT_VIEW_ID) + const selected = selectedId === null ? undefined : tabs.find(view => view.id === selectedId) + return selected ?? tabs.find(view => view.id === DEFAULT_VIEW_ID) } function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] { @@ -64,17 +65,16 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum * @returns the hidden blank-session header or visible title and tabs. */ export function ConversationSessionHeader({ - sessionId, useSession, useSessions, useStore, actions, - renderSlot, views, open, t, + sessionId, useSession, useSessions, useConversation, useConversationViews, useStore, actions, + renderSlot, open, t, }: ConversationSessionHeaderProps) { - useSyncExternalStore(views.subscribe, views.version) - const tabs = views.list() + const tabs = useConversationViews(value => value) const selectedId = useStore(s => s.view) const active = resolveActiveView(tabs, selectedId) const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs) - const composerPhase = useSession(s => s.composerPhase) - const blank = useSession(s => s.blank) - const hideChrome = blank && composerPhase === 'blank' + const session = useSession(s => s) + const conversation = useConversation(s => s) + const hideChrome = session.blank && conversationPhase(session, conversation) === 'blank' return (
value) const selectedId = useStore(s => s.view) const active = resolveActiveView(tabs, selectedId) - const composerPhase = useSession(s => s.composerPhase) - const blank = useSession(s => s.blank) + const session = useSession(s => s) + const conversation = useConversation(s => s) const inputState = useInput(s => s) const storedDraft = useStore(s => s.draft) - // `?? null`: persisted snapshots from before the inspect field rehydrate without it. - const inspect = useStore(s => s.inspect ?? null) + const viewRequest = useStore(s => s.viewRequest ?? null) useEffect(() => { if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft) @@ -193,16 +191,13 @@ export function ConversationSession({ // the machine mirror, not this seed effect. }, [inputActions]) - useEffect(() => () => { - releaseSessionImages(sessionId) - }, [releaseSessionImages, sessionId]) - - if (blank && composerPhase === 'blank') return null + if (session.blank && conversationPhase(session, conversation) === 'blank') return null return (
{active !== undefined && renderSlot('conversation.view', { - inspect, - onInspectDone: () => { actions.setInspect(null) }, + viewRequest, + openView: actions.openView, + completeViewRequest: actions.completeViewRequest, }, { only: active.id })}
) diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index c10cec74f9..2fdfe5e304 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -6,7 +6,7 @@ import type { ReactNode, RefObject } from 'react' import { FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, } from '@deepseek-ai/dsh-client-ui-primitives' -import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client' +import { workspaceTitleOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { ConversationSlotProps } from '../contract/slots.ts' import css from './HeroShell.module.css' diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 03a4116b8c..d22049b370 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -17,16 +17,14 @@ import { import type {} from '@deepseek-ai/dsh-plan-mode/client' // Type-only: the `goal` projection key merge (hint disambiguation). import type {} from '@deepseek-ai/dsh-goal/client' -// The `imageLimits` projection key merge (intake pre-check) arrives with the -// wire types: apiproxy's sessions contract declares it, and client-runtime's -// api-remotes import already places it in every client program. +// The `imageLimits` projection key merge supplies the intake pre-check. import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerBarProps } from '../contract/slots.ts' -import { deriveDecorations } from '../input/decorations.ts' -import type { DraftDecorations } from '../input/decorations.ts' -import type { EditRange } from '../input/contract.ts' +import { deriveDecorations } from './decorations.ts' +import type { DraftDecorations } from './decorations.ts' +import type { EditRange } from '../contract/input.ts' import { attachmentErrorText, imageSizeText } from '../image-labels.ts' -import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' +import { ReferenceIcon } from './ReferenceIcon.tsx' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' @@ -502,7 +500,7 @@ export function InputBar({ keyboard.track(keyboard.snapshot.draft, caret) } - // Intake pre-check (DeepSeek Chat semantics): an addition that would break + // Intake pre-check: an addition that would break // a projected limit is refused as a whole batch, announced immediately, and // never enters the rail — no more submit-time failure rolling the rail // back. The host enforces the same limits at submit for callers that bypass @@ -511,7 +509,7 @@ export function InputBar({ if (addImages === undefined || files.length === 0) return const rejected = ((): string | null => { if (imageLimits !== undefined) { - // Format precedes limits (DeepSeek Chat's filter order): a batch with + // Format precedes limits: a batch with // a non-image must announce the format problem, not a count or size // it could never pass anyway — addImages rejects it authoritatively. if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) { @@ -785,13 +783,13 @@ export function InputBar({
{accessSelect} - {renderSlot('conversation.input.plan', { locked })} + {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })}
{leftItems}
{rightItems} - {renderSlot('conversation.input.model', { locked: modelSeatLocked })} + {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} {interruptible && ( diff --git a/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx b/packages/client/ui-conversation/src/client/skeleton/ReferenceIcon.tsx similarity index 100% rename from packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx rename to packages/client/ui-conversation/src/client/skeleton/ReferenceIcon.tsx diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/skeleton/decorations.ts similarity index 98% rename from packages/client/ui-conversation/src/client/input/decorations.ts rename to packages/client/ui-conversation/src/client/skeleton/decorations.ts index 1ae5e9404b..46dcfda594 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/skeleton/decorations.ts @@ -4,7 +4,7 @@ * highlight, the claim hint as ghost text). Zero React — the skeleton renders * the instructions; tests drive this directly. */ -import type { InputState } from './contract.ts' +import type { InputState } from '../contract/input.ts' /** The claim-token highlight range (always draft-leading while the watch holds). */ export interface TokenRange { diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index 5a4913f0fa..6cbbb9523b 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/packages/client/ui-conversation/src/client/stores.ts @@ -1,34 +1,31 @@ -/** - * Per-session chat store shared by conversation and details registrations. - * The plugin creates its handle at apply time so identity follows the fiber. - */ -import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' -import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts' +/** Per-session Conversation store shared by the shell body and header. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { ConversationStoreState } from './contract/views.ts' -/** Declared action shape used to give the exported factory a stable return type. */ -type ChatActions = { - select: (draft: ChatStoreState, target: SelectionTarget | null) => void - setDraft: (draft: ChatStoreState, text: string) => void - setView: (draft: ChatStoreState, view: string) => void - setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void +/** Declared write set for the Conversation shell. */ +type ConversationActions = { + setDraft: (draft: ConversationStoreState, text: string) => void + setView: (draft: ConversationStoreState, view: string) => void + openView: (draft: ConversationStoreState, view: string, focus: string) => void + completeViewRequest: (draft: ConversationStoreState) => void } /** - * Declares the per-session chat state and write surface. + * Declare per-session draft persistence and View selection. * @returns the store handle. */ -export function createChatStore(): EngineStoreHandle { +export function createConversationStore(): EngineStoreHandle { return defineStore({ - // Anchored to the contract shape: consumers read the store through - // PropsStore's SnapshotSelectorHook, so init - // and the contract cannot drift. - init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }), - persist: 'dsh.conversation.chat', + init: (): ConversationStoreState => ({ draft: '', view: null, viewRequest: null }), + persist: 'dsh.conversation', actions: { - select: (d, target: SelectionTarget | null) => { d.selection = target }, setDraft: (d, text: string) => { d.draft = text }, setView: (d, view: string) => { d.view = view }, - setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target }, + openView: (d, view: string, focus: string) => { + d.view = view + d.viewRequest = { view, focus } + }, + completeViewRequest: (d) => { d.viewRequest = null }, }, }) } diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts index 7e0b5b9a9b..db950ebd65 100644 --- a/packages/client/ui-conversation/src/invariant.ts +++ b/packages/client/ui-conversation/src/invariant.ts @@ -15,10 +15,8 @@ export const name = 'client-ui-conversation-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the conversation service emits no cordis events, and - * both rings this package owns (the 'conversation.view' tab ring and the - * 'conversation.chat.node' business renderer seat) ride the slot system, whose ledger - * invariants live with the runtime slots package. + * No runtime invariant: Conversation Definitions, target builders, and Views + * are already validated by their owning registries and the Slot ledger. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 90b4d804cf..db5bf29baa 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -1,30 +1,29 @@ // @vitest-environment jsdom - import { describe, expect, it, vi } from 'vitest' -import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' +import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { - ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, - ConversationSessionInjected, DetailsInjected, +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import { + SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' +import { + apply, inject, type ComposerBarInjected, type ConversationInjected, + type ConversationSessionInjected, type ViewTab, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { createChatStore } from '../src/client/stores.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import { createConversationStore } from '../src/client/stores.ts' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') const ROOT = 'root-1' as SessionId -type ChatInstance = ReturnType['create']> -type ChatActions = ChatInstance['actions'] +type ConversationInstance = ReturnType['create']> +type ConversationActions = ConversationInstance['actions'] -/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */ function sessionFakeFor() { return { - open: vi.fn(() => Promise.resolve()), loadOlder: vi.fn(() => Promise.resolve()), prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), @@ -33,49 +32,33 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) - // The plugin injects both; these specs exercise no settings path. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + const connectWorkspace = vi.fn(async () => ROOT) + runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R', cwd: '/proj' }, session: sessionFake, - }) - const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() } - runtime.provide('layout', layoutFake) + }, { current: false }) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) - - // The AppFrame role: the conversation-package slots must be declared by a - // live entry before apply can contribute into them. await runtime.root.declare({ 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, (_p: { renderSlot?: unknown }) => null) + }, (_props: { renderSlot?: unknown }) => null) const feature = await runtime.mount({ inject: [...inject], apply }) - - // The host face (store resolution) exists only inside the installed - // renderer, so materialize it the way the shell does. runtime.renderRoot() - const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') => + const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar') => runtime.slots.entries(key)[0]! - /** Resolve store instance + call the inject the way the outlet would. */ const conversationApi = (id: SessionId) => { const entry = entryOf('conversation.session') - const instance = runtime.storeOf('conversation.session', id) as ChatInstance - const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)( - id, instance.actions) - return { instance, injected } - } - const conversationHeaderApi = (id: SessionId) => { - const entry = entryOf('conversation.session.header') - const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance - const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)( - id, instance.actions) + const instance = runtime.storeOf('conversation.session', id) as ConversationInstance + const injected = (entry.inject as unknown as ( + sessionId: SessionId, + actions: ConversationActions, + ) => ConversationSessionInjected)(id, instance.actions) return { instance, injected } } const residentApi = (id: SessionId | undefined) => { @@ -86,264 +69,151 @@ async function bench() { const entry = entryOf('conversation.composer.bar') return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id) } - /** Same resolution for the chat entry riding the view ring. */ - const chatViewApi = (id: SessionId) => { - const entry = entryOf('conversation.view') - const instance = runtime.storeOf('conversation.view', id) as ChatInstance - const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)( - id, instance.actions) - return { instance, injected } - } - /** Materialize the input provide contribution the way the runtime does. */ const inputApi = (id: SessionId) => { - const info = runtime.sessions.provideInfo(id)! - const state = info.hooks['input'] as { - getSnapshot: () => { draft: string } - subscribe: (fn: () => void) => () => void - } - const actions = info.props['inputActions'] as { - setDraft: (text: string) => void - submit: () => void - } - return { state, actions } + const input = runtime.ctx.conversation.input.for(runtime.sessions.scope(id)!) + return { state: input.state, actions: input } } + const viewSource = (id: SessionId): ObservableSnapshot => + conversationApi(id).injected.hooks.conversationViews return { - runtime, feature, slots: runtime.slots, entryOf, - conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi, - sessionFake, layoutFake, + runtime, feature, slots: runtime.slots, entryOf, conversationApi, residentApi, composerApi, + inputApi, viewSource, sessionFake, connectWorkspace, } } -describe('conversation slot inject API', () => { - it('assembles the thin API side-effect-free', async () => { +describe('Conversation inject API', () => { + it('assembles the target-neutral read face without Session side effects', async () => { const b = await bench() const { injected } = b.conversationApi(ROOT) - // Assembly has no session side effects: opening the event window belongs - // to the runtime watch path, not the inject factory. - expect(b.sessionFake.open).not.toHaveBeenCalled() - expect(injected.views.list().map(v => v.id)).toEqual(['chat']) - - const chatView = b.chatViewApi(ROOT) - chatView.injected.loadOlder() - expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) - chatView.injected.forkAt(17) - await vi.waitFor(() => { - expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) - }) - expect(b.runtime.sessions.calls).toContainEqual({ - method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }], - }) + expect(b.sessionFake.loadOlder).not.toHaveBeenCalled() + expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror']) + expect(b.viewSource(ROOT).getSnapshot()).toEqual([]) await b.runtime.dispose() }) - it('the provide-channel input face submits through the machine sink: trim, transactional clear, failure retains the draft', async () => { + it('submits through the provided input machine and mirrors accepted draft edits', async () => { const b = await bench() const { injected } = b.conversationApi(ROOT) const { state, actions } = b.inputApi(ROOT) - // Whitespace-only: the machine treats it as empty — no prompt, draft kept. actions.setDraft(' ') actions.submit() expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(state.getSnapshot().draft).toBe(' ') - // Success: the draft clears only after the sink settles. + actions.setDraft('hello') actions.submit() - await vi.waitFor(() => { - expect(state.getSnapshot().draft).toBe('') + await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') }) + expect(b.sessionFake.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), + ) + + b.sessionFake.prompt.mockResolvedValueOnce({ + ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, }) - expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal)) - // Failure: the draft is retained through the round-trip. - b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.setDraft('retry me') actions.submit() - await vi.waitFor(() => { - expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) - }) - await new Promise(r => setTimeout(r, 0)) + await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) }) + await Promise.resolve() expect(state.getSnapshot().draft).toBe('retry me') - // Failure landing after new typing: no clobber (the interleaved edit wins). - b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) - actions.submit() - actions.setDraft('typed during flight') - await new Promise(r => setTimeout(r, 0)) - expect(state.getSnapshot().draft).toBe('typed during flight') - // The provide contribution is idempotent per session: one shell identity. - expect(b.inputApi(ROOT).state).toBe(state) - // The draft mirror rides the conversation inject face. + const mirrored: string[] = [] const unbind = injected.bindDraftMirror(text => mirrored.push(text)) actions.setDraft('mirrored text') expect(mirrored).toEqual(['mirrored text']) unbind() - // Stop failure is swallowed (promptError owns the display). - b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } }) + expect(b.inputApi(ROOT).state).toBe(state) + + b.sessionFake.cancel.mockResolvedValueOnce({ + ok: false, error: { code: 'internal', message: 'stop failed', details: {} }, + }) b.composerApi(ROOT).stop!() - await new Promise(r => setTimeout(r, 0)) - expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { expect(b.sessionFake.cancel).toHaveBeenCalledOnce() }) await b.runtime.dispose() }) - it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => { + it('fails loud for an unknown binding or an unloaded scoped service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') - const injectFn = entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected - // Unknown session: the keyboard face's binding resolution answers nothing. - expect(() => { injectFn('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/) - // No session (session-maybe absent side): machine faces absent, static - // hooks compartment still present so the render side's hook order holds. - const absent = injectFn(undefined) + const injectBar = entry.inject as unknown as ( + sessionId: SessionId | undefined, + ) => ComposerBarInjected + expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/) + + const absent = injectBar(undefined) expect(absent.keyboard).toBeUndefined() expect(absent.toggleCommandMenu).toBeUndefined() expect(absent.stop).toBeUndefined() expect(absent.hooks.notices.getSnapshot()).toBeNull() expect(absent.hooks.lexicon.getSnapshot().size).toBe(0) expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull() - // A scope whose service tree lost 'conversation' (the feature fiber - // unloaded while a retained inject closure re-runs): fails loud too. - const stop = injectFn(ROOT).stop! + + const stop = injectBar(ROOT).stop! await b.feature.dispose() expect(() => { stop() }).toThrow(/unavailable through the session scope/) await b.runtime.dispose() }) - it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => { - const b = await bench() - const { instance, injected } = b.chatViewApi(ROOT) - injected.openDetails({ turnSeq: 2, callId: 'c1' }) - expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' }) - expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1) - // The chat view shares the conversation entry's store instance: selection - // writes land where the skeleton and details read. - const conv = b.conversationApi(ROOT) - expect(conv.instance).toBe(instance) - await b.runtime.dispose() - }) - - it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { - const b = await bench() - const { injected } = b.chatViewApi(ROOT) - await injected.openFile('src/a.ts') - await vi.waitFor(() => { - expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) - }) - await b.runtime.dispose() - }) - - it('openFile rejects when the Host cannot open the path', async () => { - const b = await bench() - b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available'))) - const { injected } = b.chatViewApi(ROOT) - await expect(injected.openFile('src/a.ts')).rejects.toThrow('xdg-open is not available') - await b.runtime.dispose() - }) - - it('routes workspace switching through the runtime owner, carrying the draft', async () => { + it('moves a draft only when Workspace navigation changes Session', async () => { const b = await bench() const resident = b.residentApi(ROOT) - // Same-session connect (the picked workspace resolves to this session): - // no draft movement, plain re-open. - b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT)) const { state, actions } = b.inputApi(ROOT) actions.setDraft('carry me') - void resident.selectWorkspace('workspace-1' as never) - await vi.waitFor(() => { - expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(1) - }) - expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] }) + + b.connectWorkspace.mockResolvedValueOnce(ROOT) + await resident.selectWorkspace('workspace-1' as WorkspaceId) + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) expect(state.getSnapshot().draft).toBe('carry me') - // Cross-session connect: the draft MOVES — the old machine empties, the - // new session's machine receives the text, then navigation lands there. - const OTHER = 'other-1' as SessionId - await b.runtime.sessions.add({ id: OTHER }, { current: false }) - b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER)) - void resident.selectWorkspace('workspace-2' as never) - await vi.waitFor(() => { - expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] }) - }) + + const other = 'other-1' as SessionId + await b.runtime.sessions.add({ id: other }, { current: false }) + b.connectWorkspace.mockResolvedValueOnce(other) + await resident.selectWorkspace('workspace-2' as WorkspaceId) + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] }) expect(state.getSnapshot().draft).toBe('') - expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('carry me') + expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me') await b.runtime.dispose() }) - it('selectWorkspace edge arms: no-session resident, empty-draft move, connect failure retryable', async () => { + it('supports no-Session navigation and propagates Workspace connection failure', async () => { const b = await bench() - // No-session resident (hero before any session): connect resolves and - // navigation proceeds without any draft choreography. - const noSession = b.residentApi(undefined) - b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT)) - void noSession.selectWorkspace('workspace-0' as never) - await vi.waitFor(() => { - expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) - }) + b.connectWorkspace.mockResolvedValueOnce(ROOT) + await b.residentApi(undefined).selectWorkspace('workspace-0' as WorkspaceId) + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) - // Cross-session connect with an EMPTY draft: no move, no clearing. - const OTHER = 'b9-other' as SessionId - await b.runtime.sessions.add({ id: OTHER }, { current: false }) - const resident = b.residentApi(ROOT) - const { state } = b.inputApi(ROOT) - expect(state.getSnapshot().draft).toBe('') - b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER)) - void resident.selectWorkspace('workspace-3' as never) - await vi.waitFor(() => { - expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] }) - }) - expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('') - - // Connect failure: the rejection propagates to the caller (the view owns - // the rollback) and no further navigation happens. - const opens = b.runtime.sessions.calls.filter(c => c.method === 'open').length - b.runtime.workspaces.stub('connectWorkspace', () => Promise.reject(new Error('offline'))) - await expect(resident.selectWorkspace('workspace-4' as never)).rejects.toThrow('offline') - expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(opens) + const opens = b.runtime.sessions.calls.filter(call => call.method === 'open').length + b.connectWorkspace.mockRejectedValueOnce(new Error('offline')) + await expect(b.residentApi(ROOT).selectWorkspace('workspace-4' as WorkspaceId)) + .rejects.toThrow('offline') + expect(b.runtime.sessions.calls.filter(call => call.method === 'open')).toHaveLength(opens) await b.runtime.dispose() }) - it('scopedConversation fails loud when the session resolves no scope', async () => { + it('projects the dynamic View registration ledger', async () => { const b = await bench() - // The chat-view inject resolves the scoped conversation service at inject - // time: an unlisted session hits the scope() === undefined throw directly. - const entry = b.entryOf('conversation.view') - const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: unknown) => unknown - expect(() => injectFn('never-listed' as SessionId, {})).toThrow(/resolved no scope/) - await b.runtime.dispose() - }) - - it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => { - const b = await bench() - const { injected } = b.conversationApi(ROOT) - const before = injected.views.version() + const source = b.viewSource(ROOT) + const before = source.getSnapshot() const listener = vi.fn() - const unsub = injected.views.subscribe(listener) - // A second ring rider (what ui-trajectory does in production). - const off = b.slots.register( - { name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never) - await Promise.resolve() // ledger notifications batch per microtask - expect(listener).toHaveBeenCalled() - expect(injected.views.version()).toBeGreaterThan(before) - expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2']) - // Label falls back to the id when a rider declares none. - const off2 = b.slots.register( - { name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never) - expect(injected.views.list().map(v => v.label)).toEqual(['对话', 'X', 'bare']) - off() - off2() - unsub() - await b.runtime.dispose() - }) -}) + const unsubscribe = source.subscribe(listener) + const removeNamed = b.slots.register( + { name: 'conversation.view', id: 'trajectory', order: 5, label: 'Trajectory' }, + (() => null) as never, + ) + await vi.waitFor(() => { + expect(source.getSnapshot()).toEqual([{ id: 'trajectory', label: 'Trajectory' }]) + }) + expect(listener).toHaveBeenCalledOnce() + expect(source.getSnapshot()).not.toBe(before) -describe('details inject API', () => { - it('details injects the one layout callback; selection rides the shared store instead', async () => { - const b = await bench() - const entry = b.entryOf('details') - const injected = (entry.inject as unknown as () => DetailsInjected)() - expect(Object.keys(injected)).toEqual(['closeDetails']) - injected.closeDetails() - expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1) - // The shared handle: details resolves the SAME instance conversation writes. - const conv = b.runtime.storeOf('conversation.session', ROOT) - const details = b.runtime.storeOf('details', ROOT) - expect(details).toBe(conv) + const removeBare = b.slots.register( + { name: 'conversation.view', id: 'bare', order: 6 }, + (() => null) as never, + ) + await vi.waitFor(() => { + expect(source.getSnapshot().map(view => view.label)).toEqual(['Trajectory', 'bare']) + }) + removeNamed() + removeBare() + unsubscribe() await b.runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx new file mode 100644 index 0000000000..eccf4b87a0 --- /dev/null +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { + SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, +} from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { apply, inject, type ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' + +usePinnedBrowserLanguages('zh-CN') + +const SID = 'session-1' as SessionId + +async function bench(options: { declareConversation?: boolean } = {}) { + const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + if (options.declareConversation !== false) { + await runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, (_props: { renderSlot?: unknown }) => null) + } + const feature = await runtime.mount({ inject: [...inject], apply }) + return { runtime, feature } +} + +function entry( + runtime: SlotTestRuntime, + key: 'conversation' | 'conversation.session' | 'conversation.session.header', +) { + return runtime.slots.entries(key)[0] as { store?: unknown } | undefined +} + +describe('target-neutral Conversation apply wiring', () => { + it('waits for the layout-owned conversation declaration before registering its subtree', async () => { + const b = await bench({ declareConversation: false }) + expect(b.runtime.slots.entries('conversation')).toHaveLength(0) + + await b.runtime.root.declare({ + 'conversation': { kind: 'single', scope: 'session-maybe' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, (_props: { renderSlot?: unknown }) => null) + + expect(b.runtime.slots.entries('conversation')).toHaveLength(1) + expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1) + expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1) + expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1) + await b.runtime.dispose() + }) + + it('provides both action and assembly services without installing Chat', async () => { + const b = await bench() + expect(b.runtime.ctx.get('conversation')).toBeDefined() + expect(b.runtime.ctx.get('uiConversation')).toBeDefined() + expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0) + await b.runtime.dispose() + }) + + it('owns shell slots and shares only the Conversation store', async () => { + const b = await bench() + const session = entry(b.runtime, 'conversation.session') + const header = entry(b.runtime, 'conversation.session.header') + expect(entry(b.runtime, 'conversation')?.store).toBeUndefined() + expect(session?.store).toBeDefined() + expect(header?.store).toBe(session?.store) + expect(b.runtime.slots.spec('conversation.composer')) + .toEqual({ kind: 'chain', scope: 'session' }) + expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id)) + .toEqual(['composer-enter']) + await b.runtime.dispose() + }) + + it('binds a cached locale-aware View roster only to its shell entries', async () => { + const b = await bench() + await b.runtime.sessions.add({ id: SID }, { current: false }) + expect(b.runtime.ctx.uiSession.adapter.resolve(SID)?.hooks.conversationViews).toBeUndefined() + const header = b.runtime.slots.entries('conversation.session.header')[0] + const source = (header?.inject?.() as { + hooks: { conversationViews: ObservableSnapshot } + } | undefined)?.hooks.conversationViews + expect(source).toBeDefined() + expect(source?.getSnapshot()).toBe(source?.getSnapshot()) + + const disposeView = b.runtime.slots.register({ + name: 'conversation.view', + id: 'probe', + label: () => b.runtime.ctx.locale.getSnapshot().active, + }, (() => null) as never) + await vi.waitFor(() => { + expect(source?.getSnapshot()).toEqual([{ id: 'probe', label: 'zh' }]) + }) + const chinese = source?.getSnapshot() + + b.runtime.ctx.locale.setLocale('en') + expect(source?.getSnapshot()).toEqual([{ id: 'probe', label: 'en' }]) + expect(source?.getSnapshot()).not.toBe(chinese) + + disposeView() + await vi.waitFor(() => { expect(source?.getSnapshot()).toEqual([]) }) + await b.runtime.dispose() + }) + + it('removes services, entries, and declarations with the plugin fiber', async () => { + const b = await bench() + await b.feature.dispose() + expect(b.runtime.ctx.get('conversation')).toBeUndefined() + expect(b.runtime.ctx.get('uiConversation')).toBeUndefined() + expect(b.runtime.slots.entries('conversation')).toHaveLength(0) + expect(b.runtime.slots.spec('conversation.view')).toBeUndefined() + await b.runtime.dispose() + }) +}) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index 2a2b67b128..692a53817b 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -4,10 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' import { useState } from 'react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' usePinnedBrowserLanguages('zh-CN') @@ -29,14 +30,13 @@ beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) -type AppRootProps = PropsRenderSlots<'conversation' | 'details'> +type AppRootProps = PropsRenderSlots<'conversation'> function AppRoot({ renderSlot }: AppRootProps) { return <>{renderSlot('conversation', {})} } const LAYOUT_CHILDREN = { 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, } as const function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { @@ -50,21 +50,15 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) - // The plugin injects both; these specs exercise no settings path. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, - snapshot: { - nodes: [], - ...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}), - }, + ...(opts?.blank === true ? { snapshot: { blank: true } } : {}), session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), @@ -78,13 +72,10 @@ async function bench(opts?: { blank?: boolean }) { describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) - // The plugin injects both; these specs exercise no settings path. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...inject], apply }) @@ -108,13 +99,10 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) - // The plugin injects both; these specs exercise no settings path. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.workspaces.update((draft) => { draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never @@ -140,7 +128,7 @@ describe('resident composer', () => { await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true }, - snapshot: { blank: true, composerPhase: 'blank' }, + snapshot: { blank: true }, }) expect(view.container.querySelector('[data-phase="hero"]')).toBe(root) @@ -165,9 +153,8 @@ describe('resident composer', () => { expect(hero).not.toBeNull() expect(hero!.disabled).toBe(false) - await runtime.sessions.updateSnapshot(SID, (draft) => { + await runtime.sessions.updateSessionSnapshot(SID, (draft) => { draft.blank = false - draft.composerPhase = 'active' }) expect(view.container.querySelector('textarea')).toBe(hero) await runtime.dispose() @@ -177,13 +164,10 @@ describe('resident composer', () => { describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) - // The plugin injects both; these specs exercise no settings path. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) const prompt = vi.fn(async () => ({ ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, @@ -202,7 +186,7 @@ describe('prompt rejection through the assembled composer', () => { fireEvent.keyDown(composer, { key: 'Enter' }) await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() }) - await runtime.sessions.updateSnapshot(SID, (draft) => { + await runtime.sessions.updateSessionSnapshot(SID, (draft) => { draft.promptError = { op: 'send', error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, diff --git a/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx deleted file mode 100644 index c4c18f2623..0000000000 --- a/packages/client/ui-conversation/tests/chat-apply.client.spec.tsx +++ /dev/null @@ -1,124 +0,0 @@ -// @vitest-environment jsdom - -import { describe, expect, it, vi } from 'vitest' -import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' - -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') - -const ROOT = 'root-1' as SessionId -const CHILD = 'child-1' as SessionId - -async function bench() { - const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) - // The plugin injects both; these specs exercise no settings path. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false }) - await runtime.sessions.add( - { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) - runtime.slots.installLocale(locale) - - // Declared by ui-layout's root entry in production; the test root declares - // them here so the contributions land. - await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - 'settings.general.item': { kind: 'list', scope: 'root' }, - }, (_p: { renderSlot?: unknown }) => null) - - const feature = await runtime.mount({ inject: [...inject], apply }) - return { runtime, feature, slots: runtime.slots } -} - -/** First stored entry for a key (inject/store live directly on StoredEntry). */ -function renderEntryOf(slots: Awaited>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') { - return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown } -} - -describe('apply wiring', () => { - it('provides the conversation service', async () => { - const b = await bench() - expect(b.runtime.ctx.get('conversation')).toBeDefined() - await b.runtime.dispose() - }) - - it('registers the chat view and its keyed business-node seat', async () => { - const b = await bench() - const entries = b.slots.entries('conversation.view') - expect(entries.map(e => e.options.id)).toEqual(['chat']) - // Label is a locale thunk resolving through the zh dictionary. - expect(resolveSlotLabel(entries[0]?.options.label)).toBe('对话') - expect(entries[0]?.options.order).toBe(0) - // Declaring is claiming: the chat entry's registration put the hole on - // the ledger with the contract's kind/scope. - const nodeSlot = b.slots.spec('conversation.chat.node') - expect(nodeSlot).toMatchObject({ kind: 'keyed', scope: 'session' }) - expect(nodeSlot?.inject?.hooks?.turnData).toBeTypeOf('function') - await b.runtime.dispose() - }) - - it('occupies the slots + the ring; session entries share one store handle', async () => { - const b = await bench() - const conversation = renderEntryOf(b.slots, 'conversation') - const conversationSession = renderEntryOf(b.slots, 'conversation.session') - const conversationHeader = renderEntryOf(b.slots, 'conversation.session.header') - const chatView = renderEntryOf(b.slots, 'conversation.view') - const details = renderEntryOf(b.slots, 'details') - expect(conversation?.inject).toBeTypeOf('function') - expect(chatView?.inject).toBeTypeOf('function') - expect(details?.inject).toBeTypeOf('function') - // The shared handle: one apply-built store value on ALL session entries - // (the session-maybe 'conversation' shell carries no store by design). - expect(conversationSession?.store).toBeDefined() - expect(conversationHeader?.store).toBe(conversationSession?.store) - expect(details?.store).toBe(conversationSession?.store) - expect(chatView?.store).toBe(conversationSession?.store) - // The hero holes ride the conversation entry's children declaration (the - // empty-state occupant is gone). Both are root-scoped: the new-session - // screen precedes the session either would belong to. - expect(b.slots.spec('conversation.hero.brand.mark')).toEqual({ kind: 'single', scope: 'root' }) - expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) - expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' }) - expect(b.slots.spec('conversation.session.header.lineage')) - .toEqual({ kind: 'single', scope: 'session' }) - expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter']) - await b.runtime.dispose() - }) - - it('leaves per-Tool rows to the ui-tool plugin', async () => { - const b = await bench() - // The actual toolview declaration activates every registrant. The - // file-mutation registrant claims both write and edit for the diff card; the - // one search row registers under both grep and glob; the web rows register - // one component under both web tool names. - expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).not.toContain('tool-call') - // Stats stick with the composer (not inside ChatView). - expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) - await b.runtime.dispose() - }) - - it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => { - const b = await bench() - await b.feature.dispose() - expect(b.slots.entries('conversation')).toHaveLength(0) - // The declared ring collapses with its declaring entry, and the chat - // entry's keyed hole (with the sample's registration) collapses with it. - expect(b.slots.entries('conversation.view')).toHaveLength(0) - expect(b.slots.entries('conversation.chat.node')).toHaveLength(0) - expect(b.slots.spec('conversation.chat.node')).toBeUndefined() - expect(b.slots.entries('details')).toHaveLength(0) - expect(b.slots.entries('settings.general.item')).toHaveLength(0) - expect(b.runtime.ctx.get('conversation')).toBeUndefined() - await b.runtime.dispose() - }) -}) diff --git a/packages/client/ui-conversation/tests/chat-store.client.spec.ts b/packages/client/ui-conversation/tests/chat-store.client.spec.ts deleted file mode 100644 index 79bdd6ecaa..0000000000 --- a/packages/client/ui-conversation/tests/chat-store.client.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -// @vitest-environment jsdom -/** Chat-store actions, scoped persistence, and instance isolation. */ -import { beforeEach, describe, expect, it } from 'vitest' -import { createChatStore } from '../src/client/stores.ts' - -const KEY = 'dsh.conversation.chat' - -beforeEach(() => { - localStorage.clear() -}) - -describe('createChatStore', () => { - it('init shape: empty selection/draft/view', () => { - const store = createChatStore().create() - expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null }) - }) - - it('actions cover the declared write set', () => { - const store = createChatStore().create() - - store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) - expect(store.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' }) - store.actions.select(null) - expect(store.store.getSnapshot().selection).toBeNull() - - store.actions.setDraft('hello') - expect(store.store.getSnapshot().draft).toBe('hello') - - store.actions.setView('chat') - expect(store.store.getSnapshot().view).toBe('chat') - - store.actions.setInspect({ callId: 'c1' }) - expect(store.store.getSnapshot().inspect).toEqual({ callId: 'c1' }) - store.actions.setInspect(null) - expect(store.store.getSnapshot().inspect).toBeNull() - }) - - it('persists per scope key and rehydrates a fresh instance', () => { - const handle = createChatStore() - const s1 = handle.create('sess-1') - s1.actions.setDraft('draft for one') - s1.actions.select({ turnSeq: 1 }) - - // Scope-suffixed key: each session persists separately. - expect(localStorage.getItem(`${KEY}.sess-1`)).not.toBeNull() - expect(localStorage.getItem(`${KEY}.sess-2`)).toBeNull() - - // A rebuilt instance under the same scope key rehydrates the state. - const again = createChatStore().create('sess-1') - expect(again.store.getSnapshot().draft).toBe('draft for one') - expect(again.store.getSnapshot().selection).toEqual({ turnSeq: 1 }) - - // A sibling scope starts clean. - const other = createChatStore().create('sess-2') - expect(other.store.getSnapshot().draft).toBe('') - }) - - it('clearPersisted removes the scope entry (session-death cleanup hook)', () => { - const store = createChatStore().create('sess-9') - store.actions.setDraft('doomed') - expect(localStorage.getItem(`${KEY}.sess-9`)).not.toBeNull() - store.clearPersisted() - expect(localStorage.getItem(`${KEY}.sess-9`)).toBeNull() - }) - - it('every create() is an independent instance; the factory holds no singleton', () => { - const handle = createChatStore() - const a = handle.create() - const b = handle.create() - a.actions.setDraft('only in a') - expect(b.store.getSnapshot().draft).toBe('') - // Two factory calls likewise share no LIVE state (identity is per handle - // VALUE, not per module — the sharing contract lives in the framework's - // handle x scope-key resolution, not in module state). Persistence is the - // one sanctioned cross-instance channel: clear it so this assertion sees - // memory identity, not rehydration (covered by the persist case above). - localStorage.clear() - const c = createChatStore().create() - expect(c.store.getSnapshot().draft).toBe('') - }) -}) diff --git a/packages/client/runtime/tests/context-provenance.client.spec.ts b/packages/client/ui-conversation/tests/context-provenance.client.spec.ts similarity index 97% rename from packages/client/runtime/tests/context-provenance.client.spec.ts rename to packages/client/ui-conversation/tests/context-provenance.client.spec.ts index b64902719c..3259aac23e 100644 Binary files a/packages/client/runtime/tests/context-provenance.client.spec.ts and b/packages/client/ui-conversation/tests/context-provenance.client.spec.ts differ diff --git a/packages/client/runtime/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts similarity index 92% rename from packages/client/runtime/tests/conversation-assembler.client.spec.ts rename to packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index ea349d6dbe..ed2c25bd15 100644 --- a/packages/client/runtime/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConversationEventInput, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, -} from '../src/client/contract/conversation.ts' +} from '@deepseek-ai/dsh-client-ui-conversation/client' interface ScopeProbeStepData { readonly value: number @@ -14,7 +14,7 @@ interface ScopeProbeTurnData { readonly valueSeenFromStep: number } -declare module '../src/client/contract/conversation.ts' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationStepDataMap { 'scope-probe': ScopeProbeStepData } @@ -62,7 +62,7 @@ function testView( apply = vi.fn(), ): ConversationViewDefinition { return { - target: 'chat', + target: 'test', create: () => { let current: TestSnapshot = { order: [], nodes: new Map() } return { @@ -92,11 +92,11 @@ function at(seq: number, type: string, data: unknown): SessionEvent { } function input(event: SessionEvent): ConversationEventInput { - return { event, view: undefined } + return { event } } -function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined { - return assembler.snapshot('chat') as TestSnapshot | undefined +function testSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined { + return assembler.snapshot('test') as TestSnapshot | undefined } function node( @@ -107,7 +107,7 @@ function node( key: context.key, kind: context.kind, id: context.id, - target: 'chat', + target: 'test', data, } } @@ -115,7 +115,7 @@ function node( function fallbackDefinition(start: () => string): ConversationNodeDefinition { return { kind: 'fallback', - target: 'chat', + target: 'test', match: event => ({ id: String(event.seq), role: 'start' }), start, update: context => context.state, @@ -142,7 +142,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -165,7 +165,7 @@ describe('ConversationNodeAssembler', () => { expect(starts).not.toHaveBeenCalled() expect(updates).toHaveBeenCalledOnce() - const snapshot = chatSnapshot(assembler) + const snapshot = testSnapshot(assembler) expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([ { callSeq: 1, results: 1 }, { callSeq: 2, results: 0 }, @@ -194,7 +194,7 @@ describe('ConversationNodeAssembler', () => { matchCollections.add(context.matches) return updates(context) }, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -212,7 +212,7 @@ describe('ConversationNodeAssembler', () => { expect(starts).not.toHaveBeenCalled() expect(updates).toHaveBeenCalledTimes(1_000) expect(matchCollections.size).toBe(1) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000) }) it('merges an older page and replays its affected Context once', () => { @@ -230,7 +230,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -256,7 +256,7 @@ describe('ConversationNodeAssembler', () => { expect(starts).toHaveBeenCalledOnce() expect(updates).toHaveBeenCalledTimes(200) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200) }) it('collects an update before its start and replays it once prepend supplies the start', () => { @@ -270,7 +270,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => ({ settled: false }), update: updates, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state ?? { pendingStart: true }), } const assembler = new ConversationNodeAssembler( @@ -283,7 +283,7 @@ describe('ConversationNodeAssembler', () => { message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, }))], true) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ pendingStart: true }) assembler.prepend([input(at(5, 'tool/call', { @@ -292,7 +292,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(updates).toHaveBeenCalledOnce() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ settled: true }) }) @@ -304,7 +304,7 @@ describe('ConversationNodeAssembler', () => { : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: () => null, } const assembler = new ConversationNodeAssembler( @@ -326,7 +326,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0), update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: () => null, } const consumerStart = vi.fn(( @@ -341,7 +341,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -352,7 +352,7 @@ describe('ConversationNodeAssembler', () => { turn: 2, step: 1, message: { role: 'assistant', content: [] }, }))], true) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) assembler.prepend([input(at(5, 'user/message', { id: 'm1', value: 7, content: [], source: { kind: 'user' }, @@ -360,7 +360,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(consumerStart).toHaveBeenCalledTimes(2) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7) }) it('keeps the predecessor index ordered across prepend and append', () => { @@ -371,7 +371,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, match) => match.event.seq, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: () => null, } const consumer: ConversationNodeDefinition = { @@ -381,7 +381,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, _match, reader) => reader.previous('source')?.state ?? -1, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -409,7 +409,7 @@ describe('ConversationNodeAssembler', () => { }))) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) .toEqual([40, 60]) }) @@ -426,7 +426,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -442,7 +442,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(consumerStart).toHaveBeenCalledTimes(2) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) }) it('replays direct dependents when an append revises their predecessor Context', () => { @@ -455,7 +455,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 1, update: (_context, match) => (match.event.data as unknown as { value: number }).value, - target: 'chat', + target: 'test', buildViewNode: () => null, } const consumerStart = vi.fn(( @@ -470,7 +470,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -487,7 +487,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(consumerStart).toHaveBeenCalledTimes(2) - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2) }) it('replays a transitive dependency closure in start order', () => { @@ -500,7 +500,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 1, update: (_context, match) => (match.event.data as unknown as { value: number }).value, - target: 'chat', + target: 'test', buildViewNode: () => null, } const sourceX: ConversationNodeDefinition = { @@ -512,7 +512,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 10, update: (_context, match) => (match.event.data as unknown as { value: number }).value, - target: 'chat', + target: 'test', buildViewNode: () => null, } const middle: ConversationNodeDefinition = { @@ -525,7 +525,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous('diamond-x')?.state ?? 0) ), update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const consumer: ConversationNodeDefinition = { @@ -538,7 +538,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous('diamond-b')?.state ?? 0) ), update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -556,7 +556,7 @@ describe('ConversationNodeAssembler', () => { assembler.append(input(at(6, 'diamond/a', { value: 2 }))) assembler.flush() - const value = [...chatSnapshot(assembler)?.nodes.values() ?? []] + const value = [...testSnapshot(assembler)?.nodes.values() ?? []] .find(candidate => candidate.kind === 'diamond-c') expect(value?.data).toBe(222) }) @@ -574,7 +574,7 @@ describe('ConversationNodeAssembler', () => { : null, start: starts, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -586,14 +586,14 @@ describe('ConversationNodeAssembler', () => { input(at(2, 'step/start', { turn: 1, step: 1 })), ], false) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open') + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open') assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 }))) assembler.flush() expect(starts).toHaveBeenCalledTimes(2) expect(apply).toHaveBeenCalledOnce() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed') + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed') }) it('lets one Context publish Step and Turn data in phase order', () => { @@ -646,7 +646,7 @@ describe('ConversationNodeAssembler', () => { value: { valueSeenFromStep: stepValue ?? -1 }, } }, - target: 'chat', + target: 'test', buildViewNode: (context) => { const location = context.start?.location if (location?.kind !== 'step') return null @@ -665,13 +665,13 @@ describe('ConversationNodeAssembler', () => { input(at(2, 'step/start', { turn: 1, step: 1 })), ], false) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ step: 1, turn: 1 }) assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 }))) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toEqual({ step: 2, turn: 2 }) }) @@ -684,7 +684,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.start?.location.kind === 'turn' ? context.start.location.turn.steps.length : -1), @@ -695,13 +695,13 @@ describe('ConversationNodeAssembler', () => { ) assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0) assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 }))) assembler.flush() expect(apply).toHaveBeenCalledOnce() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) }) it('publishes a changed timeline even when no business Definition claims the boundary', () => { @@ -717,7 +717,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(apply).toHaveBeenCalledOnce() - expect(chatSnapshot(assembler)?.order).toEqual([]) + expect(testSnapshot(assembler)?.order).toEqual([]) }) it('clears the prior Step at a new Turn and honors explicit session ownership', () => { @@ -740,7 +740,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: (context) => { const location = context.start?.location const data = location?.kind === 'step' @@ -762,7 +762,7 @@ describe('ConversationNodeAssembler', () => { ], false) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) .toEqual(['turn:2', 'session']) }) @@ -774,7 +774,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.start?.location.kind), } const assembler = new ConversationNodeAssembler( @@ -790,7 +790,7 @@ describe('ConversationNodeAssembler', () => { assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } }))) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn') + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn') }) it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => { @@ -801,7 +801,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: (context) => { const location = context.start?.location return node(context, location?.kind === 'step' @@ -822,7 +822,7 @@ describe('ConversationNodeAssembler', () => { assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' }))) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) .toEqual(['2:3', '2:3']) }) @@ -834,7 +834,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: (context) => { const location = context.start?.location return node(context, location?.kind === 'step' @@ -853,7 +853,7 @@ describe('ConversationNodeAssembler', () => { ], true) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data) .toBe('closed:closed') }) @@ -869,7 +869,7 @@ describe('ConversationNodeAssembler', () => { : null, start: seen, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -904,7 +904,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(fallbackStart).toHaveBeenCalledOnce() - expect(chatSnapshot(assembler)?.order).toHaveLength(1) + expect(testSnapshot(assembler)?.order).toHaveLength(1) }) it('invokes the fallback when only another target claims an event', () => { @@ -928,14 +928,14 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(fallbackStart).toHaveBeenCalledOnce() - expect(chatSnapshot(assembler)?.order).toHaveLength(1) + expect(testSnapshot(assembler)?.order).toHaveLength(1) }) it('suppresses the fallback when the same target claims an event', () => { const fallbackStart = vi.fn(() => 'fallback') const claimed: ConversationNodeDefinition = { kind: 'claimed', - target: 'chat', + target: 'test', match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null, start: () => null, update: context => context.state, @@ -949,7 +949,7 @@ describe('ConversationNodeAssembler', () => { assembler.flush() expect(fallbackStart).not.toHaveBeenCalled() - expect(chatSnapshot(assembler)?.order).toEqual([]) + expect(testSnapshot(assembler)?.order).toEqual([]) }) it('rejects withdrawing a previously materialized Node during an incremental update', () => { @@ -962,7 +962,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => false, - target: 'chat', + target: 'test', buildViewNode: context => context.state === true ? node(context, true) : null, } const assembler = new ConversationNodeAssembler( @@ -971,12 +971,12 @@ describe('ConversationNodeAssembler', () => { ) assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) assembler.flush() - expect(chatSnapshot(assembler)?.order).toHaveLength(1) + expect(testSnapshot(assembler)?.order).toHaveLength(1) assembler.append(input(at(2, 'toggle/hide', {}))) - expect(() => assembler.flush()).toThrow(/withdrew materialized target "chat"/) + expect(() => assembler.flush()).toThrow(/withdrew materialized target "test"/) - expect(chatSnapshot(assembler)?.order).toHaveLength(1) + expect(testSnapshot(assembler)?.order).toHaveLength(1) }) it('fails loud when a Definition returns undefined State', () => { @@ -985,7 +985,7 @@ describe('ConversationNodeAssembler', () => { match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, start: () => undefined, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: () => null, } const startAssembler = new ConversationNodeAssembler( @@ -1005,7 +1005,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => undefined as never, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const updateAssembler = new ConversationNodeAssembler( @@ -1026,7 +1026,7 @@ describe('ConversationNodeAssembler', () => { match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, start: (_context, match) => match.event.seq, update: context => context.state, - target: 'chat', + target: 'test', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -1042,6 +1042,6 @@ describe('ConversationNodeAssembler', () => { input(at(2, 'command/run', { commandId: 'two', name: 'x' })), )).toThrow(/received more than one start Match/) assembler.flush() - expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) }) }) diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts new file mode 100644 index 0000000000..3df509dcdf --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -0,0 +1,247 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { + createScope, MutableSessionEventSource, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { + ISessions, SessionBinding, SessionFace, SessionListState, SessionSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import { + ConversationEventRegistry, ConversationNodeAssembler, ConversationViewRegistry, UiConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +const SESSION_ID = 'resident' as SessionId + +function sessionSnapshot(): SessionSnapshot { + return { + sessionId: SESSION_ID, + queue: [], + running: false, + subagent: null, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: true, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, + } +} + +function fakeSession(): SessionFace { + const snapshot = createSnapshotStore(sessionSnapshot()) + return { + sessionId: SESSION_ID, + projections: { faceOf: () => createSnapshotStore(undefined) }, + getSnapshot: () => snapshot.getSnapshot(), + subscribe: listener => snapshot.subscribe(listener), + prompt: () => Promise.reject(new Error('unused fake Session operation')), + readAttachment: () => Promise.reject(new Error('unused fake Session operation')), + updateQueue: () => Promise.reject(new Error('unused fake Session operation')), + cancel: () => Promise.reject(new Error('unused fake Session operation')), + rename: () => Promise.reject(new Error('unused fake Session operation')), + loadOlder: () => Promise.reject(new Error('unused fake Session operation')), + command: () => Promise.reject(new Error('unused fake Session operation')), + } +} + +function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBinding } { + const scope = createScope(ctx, SESSION_ID) + const binding: SessionBinding = { + sessionId: SESSION_ID, + session: fakeSession(), + eventSource: new MutableSessionEventSource(), + ctx: scope.ctx, + } + const list = createSnapshotStore({ + ids: [], + byId: {}, + current: undefined, + phase: 'ready', + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) + const sessions = { + list, + searchResultLimit: 50, + create: () => Promise.reject(new Error('unused fake Sessions operation')), + open: () => {}, + openSubagent: () => {}, + subagentAddress: () => undefined, + setSubagentCatalogOpen: () => {}, + refreshSubagents: () => Promise.reject(new Error('unused fake Sessions operation')), + noteAgentPreset: () => {}, + clear: () => {}, + refresh: () => Promise.reject(new Error('unused fake Sessions operation')), + search: () => Promise.reject(new Error('unused fake Sessions operation')), + fork: () => Promise.reject(new Error('unused fake Sessions operation')), + scope: id => id === SESSION_ID ? binding.ctx : undefined, + scopeOf: candidate => candidate === binding.ctx ? SESSION_ID : undefined, + sessionOf: candidate => candidate === binding.ctx ? binding.session : undefined, + binding: id => id === SESSION_ID ? binding : undefined, + } satisfies ISessions + return { sessions, binding } +} + +function eventDefinition(kind: string): ConversationNodeDefinition { + return { + kind, + target: 'chat', + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } +} + +function viewDefinition(target: string): ConversationViewDefinition { + return { + target, + create: () => ({ + empty: null, + replace: () => null, + apply: () => null, + }), + } +} + +async function bootRegistries(): Promise<{ + ctx: Context + uiConversation: UiConversation + binding: SessionBinding + events: ConversationEventRegistry + views: ConversationViewRegistry +}> { + const ctx = new Context() + const { sessions, binding } = fakeSessions(ctx) + const uiConversation = new UiConversation(ctx, sessions) + return { + ctx, + uiConversation, + binding, + events: uiConversation.events, + views: uiConversation.views, + } +} + +describe('Conversation registries', () => { + it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => { + const { events } = await bootRegistries() + const definition = eventDefinition('message') + const dispose = events.register(definition) + + expect(events.entries()).toEqual([definition]) + expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.entries()).toEqual([]) + }) + + it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => { + const { events } = await bootRegistries() + const fallback = eventDefinition('unknown') + const dispose = events.registerFallback(fallback) + + expect(events.fallbackEntry()).toBe(fallback) + expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.fallbackEntry()).toBeUndefined() + }) + + it('rejects rendering Definitions that omit either target or builder', async () => { + const { events } = await bootRegistries() + const targetOnly: ConversationNodeDefinition = { + kind: 'target-only', + target: 'chat', + match: () => null, + start: () => null, + update: context => context.state, + } + const builderOnly: ConversationNodeDefinition = { + kind: 'builder-only', + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + + expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/) + expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/) + }) + + it('rejects a State-only Definition as the unmatched-event fallback', async () => { + const { events } = await bootRegistries() + const fallback: ConversationNodeDefinition = { + kind: 'state-only-fallback', + match: () => null, + start: () => null, + update: context => context.state, + } + + expect(() => events.registerFallback(fallback)) + .toThrow('conversation fallback Definition must declare a target') + }) + + it('rejects duplicate view targets and disposes a view registration once', async () => { + const { views } = await bootRegistries() + const definition = viewDefinition('chat') + const dispose = views.register(definition) + + expect(views.entries()).toEqual([definition]) + expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/) + + dispose() + dispose() + expect(views.entries()).toEqual([]) + }) + + it('removes Event, fallback, and view contributions with their caller fiber', async () => { + const { ctx, events, views } = await bootRegistries() + const feature = ctx.inject(['uiConversation'], (featureCtx) => { + featureCtx.uiConversation.events.register(eventDefinition('message')) + featureCtx.uiConversation.events.registerFallback(eventDefinition('unknown')) + featureCtx.uiConversation.views.register(viewDefinition('chat')) + }) + await feature.await() + + expect(events.entries()).toHaveLength(1) + expect(events.fallbackEntry()).toBeDefined() + expect(views.entries()).toHaveLength(1) + + await feature.dispose() + expect(events.entries()).toEqual([]) + expect(events.fallbackEntry()).toBeUndefined() + expect(views.entries()).toEqual([]) + }) + + it('coalesces one turn of registry changes into one rebuild per resident Conversation', async () => { + const { uiConversation, binding, events, views } = await bootRegistries() + uiConversation.binding(binding) + const rebuild = vi.spyOn(ConversationNodeAssembler.prototype, 'rebuildRegistry') + + events.register(eventDefinition('message')) + views.register(viewDefinition('chat')) + events.register(eventDefinition('tool')) + expect(rebuild).not.toHaveBeenCalled() + + await Promise.resolve() + expect(rebuild).toHaveBeenCalledOnce() + + views.register(viewDefinition('trajectory')) + await Promise.resolve() + expect(rebuild).toHaveBeenCalledTimes(2) + rebuild.mockRestore() + }) +}) diff --git a/packages/client/ui-conversation/tests/conversation-store.client.spec.ts b/packages/client/ui-conversation/tests/conversation-store.client.spec.ts new file mode 100644 index 0000000000..6ba0b43f4b --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-store.client.spec.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from 'vitest' +import { createConversationStore } from '../src/client/stores.ts' + +const KEY = 'dsh.conversation' + +beforeEach(() => { + localStorage.clear() +}) + +describe('createConversationStore', () => { + it('owns draft, selected View, and one-shot View requests', () => { + const store = createConversationStore().create() + expect(store.store.getSnapshot()).toEqual({ draft: '', view: null, viewRequest: null }) + + store.actions.setDraft('hello') + store.actions.setView('chat') + expect(store.store.getSnapshot()).toEqual({ + draft: 'hello', + view: 'chat', + viewRequest: null, + }) + + store.actions.openView('trajectory', 'call-1') + expect(store.store.getSnapshot()).toMatchObject({ + view: 'trajectory', + viewRequest: { view: 'trajectory', focus: 'call-1' }, + }) + store.actions.completeViewRequest() + expect(store.store.getSnapshot().viewRequest).toBeNull() + }) + + it('persists per Session scope and clears the persisted value', () => { + const first = createConversationStore().create('sess-1') + first.actions.setDraft('draft for one') + first.actions.setView('chat') + expect(localStorage.getItem(`${KEY}.sess-1`)).not.toBeNull() + expect(localStorage.getItem(`${KEY}.sess-2`)).toBeNull() + + const restored = createConversationStore().create('sess-1') + expect(restored.store.getSnapshot()).toMatchObject({ + draft: 'draft for one', + view: 'chat', + }) + + first.clearPersisted() + expect(localStorage.getItem(`${KEY}.sess-1`)).toBeNull() + }) + + it('creates independent live instances', () => { + const handle = createConversationStore() + const first = handle.create() + const second = handle.create() + first.actions.setDraft('only first') + expect(second.store.getSnapshot().draft).toBe('') + }) +}) diff --git a/packages/client/ui-conversation/tests/coverage-tails.client.spec.ts b/packages/client/ui-conversation/tests/coverage-tails.client.spec.ts new file mode 100644 index 0000000000..4c5ebcb2f6 --- /dev/null +++ b/packages/client/ui-conversation/tests/coverage-tails.client.spec.ts @@ -0,0 +1,9 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import { apply as nodeApply } from '../src/index.ts' + +describe('node apply tail', () => { + it('tolerates a Host without settings', () => { + expect(() => { nodeApply(new Context()) }).not.toThrow() + }) +}) diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 1351e85649..fea7fa315b 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx' import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx' @@ -21,17 +24,21 @@ function emptySessions() { } function emptyWorkspaces() { - return bindSnapshotSelector(createSnapshotStore({ + return bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, })) } +function noPendingInteraction() { + return bindSnapshotSelector(createSnapshotStore(new Map())) +} + function mount() { const policy = new ComposerSubmissionPolicy() const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) }) const props: EnterBehaviorRowProps = { useSessions: emptySessions(), + useSessionPendingInteraction: noPendingInteraction(), useWorkspaces: emptyWorkspaces(), useBusyEnter: bindSnapshotSelector(policy.busyEnter), setBusyEnter, diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.ts b/packages/client/ui-conversation/tests/image-labels.client.spec.ts new file mode 100644 index 0000000000..8e1ff4409d --- /dev/null +++ b/packages/client/ui-conversation/tests/image-labels.client.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts' +import { en, zh } from '../src/client/locales.ts' + +const t = makeTranslate(zh, commonZh) +const enT = makeTranslate(en, commonZh) + +describe('attachment rejection copy', () => { + const limits = { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: ['image/png'] as const, + } + + it('renders megabytes without a trailing fraction unless one exists', () => { + expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB') + expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB') + }) + + it('maps user-solvable reasons to limit-naming copy', () => { + expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型') + expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片') + expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试') + expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') + expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') + expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片') + expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB') + expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片') + expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE', limits)).toBe('图片宽高不能超过 2000px,请缩小后重试') + expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images') + }) + + it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => { + expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试') + expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGE_DIMENSION_TOO_LARGE')).toBe('图片发送失败(IMAGE_DIMENSION_TOO_LARGE),请重新添加图片后再试') + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index c81531afe6..f36b1aa1c4 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -2,19 +2,21 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { Context } from '@deepseek-ai/cordis' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' + bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SessionInputShell } from '../src/client/input/facade.ts' import type { ComposerAttachment, ComposerAttachmentsOwnerProps, } from '../src/client/contract/slots.ts' -import type { DraftAttachmentId } from '../src/client/input/contract.ts' +import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' @@ -33,18 +35,11 @@ Range.prototype.getBoundingClientRect = ZERO_RECT const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')! .value as (this: Range, node: Node, offset: number) => void -const SCTX = {} as ClientContext +const SCTX = {} as Context const SID = 's1' as SessionId -function snapshotOf(overrides: Partial = {}): ConversationSnapshot { - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, - ...overrides, - } +function snapshotOf(overrides: Partial = {}): SessionSnapshot { + return { ...sessionSnapshot(SID), ...overrides } } interface BenchOptions { @@ -66,14 +61,14 @@ interface BenchOptions { } draft?: string running?: boolean - subagent?: Exclude + subagent?: Exclude disabled?: boolean inert?: boolean workspacePickerOpen?: boolean onRequestWorkspace?: () => void - promptError?: ConversationSnapshot['promptError'] + promptError?: SessionSnapshot['promptError'] /** Authoritative queue rows served to the machine overlay (empty = none). */ - queue?: ConversationSnapshot['queue'] + queue?: SessionSnapshot['queue'] /** The hub's steer-all face (empty-draft accelerated Enter). */ steerQueue?: () => void variant?: 'hero' | 'composer' @@ -92,7 +87,7 @@ interface BenchOptions { } /** One pending queue row (the runtime snapshot shape, as the dock tests build it). */ -function row(id: string): ConversationSnapshot['queue'][number] { +function row(id: string): SessionSnapshot['queue'][number] { return { id: id as never, messageId: `message-${id}` as never, placement: 'queued', content: [{ type: 'text', text: id }], preview: id, text: id, @@ -108,7 +103,7 @@ function bench(over?: BenchOptions) { signal: AbortSignal, ) => Promise>(() => Promise.resolve({ kind: 'success' })) const lex = over?.lexicon - const session = createSnapshotStore(snapshotOf({ + const session = createSnapshotStore(snapshotOf({ running: over?.running ?? false, subagent: over?.subagent ?? null, removed: over?.disabled ?? false, @@ -149,12 +144,15 @@ function bench(over?: BenchOptions) { }) as InputBarProps['renderSlot'] const props: InputBarProps = { sessionId: SID, - SessionProvider: ({ children }) => children(SID), + SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, })), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, @@ -163,6 +161,7 @@ function bench(over?: BenchOptions) { (selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)), + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -326,7 +325,7 @@ describe('image draft rail', () => { }) it('announces server attachment rejections as product copy, other codes as developer text', () => { - const attachmentError = (reason: string): ConversationSnapshot['promptError'] => ({ + const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({ op: 'send', error: { code: 'attachment-error', message: 'raw wire text', details: { reason } }, }) diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index 01df9588a8..28e7cc4430 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -9,11 +9,11 @@ */ import { describe, expect, it } from 'vitest' import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts' +import type { InputEffect, SubmitAttempt } from '../src/client/contract/input.ts' import { InputMachine, PLACEHOLDER, projectClipboard, referenceDraftText, } from '../src/client/input/machine.ts' -import { deriveDecorations, scanTextRefs } from '../src/client/input/decorations.ts' +import { deriveDecorations, scanTextRefs } from '../src/client/skeleton/decorations.ts' const LEGACY_PLACEHOLDER = PLACEHOLDER diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 05d2e6b528..fd00297bee 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -7,15 +7,18 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { Context } from '@deepseek-ai/cordis' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { DraftAttachmentId } from '../src/client/input/contract.ts' +import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' @@ -23,31 +26,33 @@ import { zh } from '../src/client/locales.ts' afterEach(cleanup) -const SCTX = {} as ClientContext +const SCTX = {} as Context const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { - const session = createSnapshotStore({ - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', - removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, - loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, + const session = createSnapshotStore({ + ...sessionSnapshot(SID), + running: over?.running ?? false, + removed: over?.disabled ?? false, }) const props: InputBarProps = { sessionId: SID, - SessionProvider: ({ children }) => children(SID), + SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, })), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index 876f992af8..b2d1d34335 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -4,10 +4,10 @@ * accepted prompt. */ import { describe, expect, it, vi } from 'vitest' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from '@deepseek-ai/cordis' import type { InputTriggerController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SessionInputShell } from '../src/client/input/facade.ts' -import type { DraftAttachmentId } from '../src/client/input/contract.ts' +import type { DraftAttachmentId } from '../src/client/contract/input.ts' const mention = '@[Research](dsh-session:InNvdXJjZSI)' const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)' @@ -36,7 +36,7 @@ describe('reference submission', () => { it('mirrors canonical reference text so a persisted draft remains resolvable after remount', async () => { const mirror = vi.fn() const first = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: vi.fn(), commandImages, }) @@ -58,7 +58,7 @@ describe('reference submission', () => { const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const restored = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: sink, commandImages, }) @@ -84,7 +84,7 @@ describe('reference submission', () => { track: vi.fn(), } as unknown as InputTriggerController const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, commandImages, @@ -126,7 +126,7 @@ describe('reference submission', () => { track: vi.fn(), } as unknown as InputTriggerController const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, commandImages, @@ -148,7 +148,7 @@ describe('reference submission', () => { it('aborts Host-side preparation when the input shell is disposed', () => { let signal: AbortSignal | undefined const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: (_text, _imageIds, _mode, received) => { signal = received return new Promise(() => {}) @@ -166,7 +166,7 @@ describe('reference submission', () => { it('retains a rejected default message without duplicating its prompt error notice', async () => { const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: () => Promise.resolve({ kind: 'error' }), commandImages, }) @@ -185,7 +185,7 @@ describe('submit transaction hardening', () => { let settle!: (outcome: SubmitOutcome) => void const sink = vi.fn(() => new Promise((resolve) => { settle = resolve })) const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: sink, commandImages, }) @@ -206,7 +206,7 @@ describe('submit transaction hardening', () => { it('retains an image-only rejection without duplicating its prompt error notice', async () => { const sink = vi.fn(() => Promise.resolve({ kind: 'error' })) const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, defaultSink: sink, commandImages, }) @@ -222,7 +222,7 @@ describe('submit transaction hardening', () => { it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => { const track = vi.fn() const shell = new SessionInputShell({ - actx: {} as ClientContext, + actx: {} as Context, inputTriggers: () => ({ track } as unknown as InputTriggerController), defaultSink: vi.fn(), commandImages, diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 33d2209176..5bf96849e5 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -1,34 +1,32 @@ // @vitest-environment jsdom /** * Scenario-chain integration (scenarios A/C/D/H/I): the real per-session - * InputTriggerController pipeline over a real session scope (SessionRuntime over + * InputTriggerController pipeline over a real session scope (Client Sessions over * a listed host session) + a command source implementing the decision * table's relevant cells + the real SessionInput machine (scoped-event * listeners wired the way the hub does) + the real InputBar. ui-commands * itself is not a dependency of this package; the source below is the * decision-table contract at the `InputTriggerSource` boundary. */ -import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { - EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, SessionRuntime, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import { FakeApiClient, fakeRemote, ok } from '../../runtime/tests/fake-api.client.ts' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { + bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { DraftAttachmentId } from '../src/client/input/contract.ts' +import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' afterEach(cleanup) @@ -102,22 +100,17 @@ const COMMANDS: FakeCommand[] = [ const PNG: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } -/** Real scope bench: SessionRuntime over one listed session + InputTriggerController + shell listeners (the hub wiring shape). */ +/** Real scope bench: a Controller-owned Session scope + InputTriggerController + shell listeners. */ async function scopedBench(register?: (inputTriggers: InputTriggerService) => void) { - const ctx = new Context() - const api = new FakeApiClient() - api.onWorkspaceList = () => Promise.resolve(ok({ items: [] })) - const sessionId = 'scenario-s1' as Parameters[0] - api.onList = () => Promise.resolve(ok({ - items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }], - }) as never) - const sessions = new SessionRuntime(ctx, api, fakeRemote()) // provides 'sessions' itself - await sessions.refresh() - await Promise.resolve() // manager notifier flush + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const ctx = runtime.ctx + const sessionId = 'scenario-s1' as SessionId + await runtime.sessions.add({ id: sessionId, summary: { cwd: '/w/a' } }) await ctx.plugin(InputTriggerService).await() const inputTriggers = ctx.get('inputTriggers') as InputTriggerService register?.(inputTriggers) - const actx = sessions.scope(sessionId)! + const actx = runtime.sessions.scope(sessionId)! const controller = inputTriggers.sessionOf(actx) const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const serialize = vi.fn((ids: readonly DraftAttachmentId[]) => Promise.resolve(ids.map(() => PNG))) @@ -128,26 +121,24 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined) actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell - const sessionStore = createSnapshotStore({ - sessionId, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, - }) + const sessionStore = createSnapshotStore(sessionSnapshot(sessionId)) const barProps: InputBarProps = { sessionId, - SessionProvider: ({ children }) => children(sessionId), + SessionProvider: ({ children }) => children, useSession: bindSnapshotSelector(sessionStore), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, })), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -184,7 +175,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo const type = (text: string): void => { fireEvent.change(textarea, { target: { value: text } }) } - return { ctx, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release } + return { runtime, inputTriggers, controller, shell, wiring, view, textarea, type, sink, serialize, release } } async function bench(executeImpl?: (line: string) => Promise) { diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 861e8db7d6..6a30ed4d8a 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -6,17 +6,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' -import { - EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, QueuedMessage, SessionId, SessionListState, -} from '@deepseek-ai/dsh-client-runtime/client' + QueuedMessage, SessionListState, SessionSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { + bindSnapshotSelector, conversationSnapshot, makeTranslate, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { QueueItemId } from '../src/client/contract/queue.ts' -import type { InputState } from '../src/client/input/contract.ts' +import type { InputState } from '../src/client/contract/input.ts' import { zh } from '../src/client/locales.ts' import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx' @@ -33,20 +35,19 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu } } -function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { +function snapshotWith(queue: QueuedMessage[]): SessionSnapshot { return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, + sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null, + hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, + lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false, } } /** Minimal live source backing the useSession stub. */ -function liveSession(initial: ConversationSnapshot) { +function liveSession(initial: SessionSnapshot) { let snapshot = initial const listeners = new Set<() => void>() - const useSession: SnapshotSelectorHook = selector => + const useSession: SnapshotSelectorHook = selector => useSyncExternalStore( (listener) => { listeners.add(listener) @@ -56,7 +57,7 @@ function liveSession(initial: ConversationSnapshot) { ) return { useSession, - push(next: ConversationSnapshot): void { + push(next: SessionSnapshot): void { snapshot = next for (const listener of [...listeners]) listener() }, @@ -67,13 +68,19 @@ const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: ' const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) -function kitFor(snapshot: ConversationSnapshot, injected: Partial = {}) { +function kitFor(snapshot: SessionSnapshot, injected: Partial = {}) { return { sessionId: SID, t, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: (() => { throw new Error('unused') }) as never, useProjection: (() => undefined) as never, + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), + useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'], + useTrajectory: (() => { throw new Error('unused') }) as QueueDockProps['useTrajectory'], useInput: (() => { throw new Error('unused') }) as never, inputActions: { setDraft: () => {}, submit: () => {} } as never, session: snapshot, diff --git a/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx index 76ed4e0de5..403d0a072a 100644 --- a/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx +++ b/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx @@ -1,110 +1,88 @@ // @vitest-environment jsdom -/** - * Exercises selection persistence through the real SlotRegistry store axis; - * component stubs cannot prove per-session identity or disposal. - */ +/** Exercises Conversation persistence through the real SlotRegistry store axis. */ import { beforeEach, describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import { createChatStore } from '../src/client/stores.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { createConversationStore } from '../src/client/stores.ts' -const sid = (s: string): SessionId => s as SessionId +const sid = (value: string): SessionId => value as SessionId -type ChatInstance = ReturnType['create']> +type ConversationInstance = ReturnType['create']> -async function bench() { +async function createBench() { const runtime = await SlotTestRuntime.create() - const chat = createChatStore() - // The apply.ts shape: one shared handle across the strict Session header, - // body, and details registrations; the session-maybe 'conversation' shell - // carries no store by design. The slots must first exist in the ledger. + const conversation = createConversationStore() await runtime.root.declare({ - 'conversation': { kind: 'single', scope: 'session-maybe' }, 'conversation.session': { kind: 'single', scope: 'session' }, 'conversation.session.header': { kind: 'single', scope: 'session' }, - 'details': { kind: 'single', scope: 'session' }, - }, (_p: { renderSlot?: unknown }) => null) - runtime.slots.register({ name: 'conversation.session', store: chat }, () => null) - runtime.slots.register({ name: 'conversation.session.header', store: chat }, () => null) - runtime.slots.register({ name: 'details', store: chat }, () => null) - runtime.renderRoot() // materializes the host face storeOf resolves through - return { runtime, chat } + }, (_props: PropsRenderSlots<'conversation.session' | 'conversation.session.header'>) => null) + runtime.slots.register({ name: 'conversation.session', store: conversation }, () => null) + runtime.slots.register({ name: 'conversation.session.header', store: conversation }, () => null) + runtime.renderRoot() + return { runtime } } -/** Resolve the store instance the renderer would hand a slot's component for a session. */ -function storeFor(b: Awaited>, slot: 'conversation.session' | 'details', sessionId: SessionId) { - return b.runtime.storeOf(slot, sessionId) as ChatInstance +function storeFor( + current: Awaited>, + slot: 'conversation.session' | 'conversation.session.header', + sessionId: SessionId, +): ConversationInstance { + return current.runtime.storeOf(slot, sessionId) as ConversationInstance } beforeEach(() => { localStorage.clear() }) -describe('selection survives on the store seat', () => { - it('one session, two slots: conversation writes, details reads the SAME instance', async () => { - const b = await bench() - - const conv = storeFor(b, 'conversation.session', sid('s1')) - const details = storeFor(b, 'details', sid('s1')) - conv.actions.select({ turnSeq: 3, callId: 'c1' }) - expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) - // Identity, not just value: the shared handle resolves one instance per scope key. - expect(details).toBe(conv) - await b.runtime.dispose() - }) - - it('sessions are isolated: s2 selection never bleeds into s1', async () => { - const b = await bench() - - const one = storeFor(b, 'conversation.session', sid('s1')) - const two = storeFor(b, 'conversation.session', sid('s2')) - expect(two).not.toBe(one) - one.actions.select({ turnSeq: 1, callId: 'a' }) - two.actions.select({ turnSeq: 9, callId: 'z' }) - expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' }) - expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' }) - await b.runtime.dispose() - }) - - it('a list-projection update keeps instance identity and the selection value', async () => { - const b = await bench() - const id = sid('s1') - - const store = storeFor(b, 'conversation.session', id) - store.actions.select({ turnSeq: 3, callId: 'c1' }) - store.actions.setDraft('half-typed') - - // A projection churn elsewhere (list rows re-projected) must not touch - // store identity: drive the runtime's own list observable. - await b.runtime.sessions.add({ id, summary: { displayTitle: 'proj-a' } }) - expect(b.runtime.sessions.list.getSnapshot().byId[id]?.displayTitle).toBe('proj-a') - - const after = storeFor(b, 'conversation.session', id) - expect(after).toBe(store) - expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' }) - expect(after.store.getSnapshot().draft).toBe('half-typed') - await b.runtime.dispose() - }) - - it('session death buries the instance and its persisted draft', async () => { - const b = await bench() +describe('Conversation state survives on its store seat', () => { + it('shares one instance between the Session body and header', async () => { + const b = await createBench() await b.runtime.sessions.add({ id: 's1' }) + const body = storeFor(b, 'conversation.session', sid('s1')) + const header = storeFor(b, 'conversation.session.header', sid('s1')) + body.actions.setDraft('half-typed') + header.actions.setView('trajectory') + + expect(header).toBe(body) + expect(body.store.getSnapshot()).toMatchObject({ draft: 'half-typed', view: 'trajectory' }) + await b.runtime.dispose() + }) + + it('isolates Session instances and preserves identity across list projection updates', async () => { + const b = await createBench() + const oneId = sid('s1') + await b.runtime.sessions.add({ id: 's1' }) + await b.runtime.sessions.add({ id: 's2' }) + const one = storeFor(b, 'conversation.session', oneId) + const two = storeFor(b, 'conversation.session', sid('s2')) + one.actions.setDraft('only one') + two.actions.setDraft('only two') + + await b.runtime.sessions.updateSummary(oneId, { displayTitle: 'projected' }) + + expect(storeFor(b, 'conversation.session', oneId)).toBe(one) + expect(one.store.getSnapshot().draft).toBe('only one') + expect(two.store.getSnapshot().draft).toBe('only two') + await b.runtime.dispose() + }) + + it('buries the instance and persisted draft with the Session scope', async () => { + const b = await createBench() + await b.runtime.sessions.add({ id: 's1' }) const doomed = storeFor(b, 'conversation.session', sid('s1')) doomed.actions.setDraft('to be buried') - doomed.actions.select({ turnSeq: 1 }) - expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull() + doomed.actions.setView('chat') + expect(localStorage.getItem('dsh.conversation.s1')).not.toBeNull() - // TestSessions.remove drives the same public slot lifecycle contract the - // production SessionRuntime calls when the scope dies (pruneStoreScope). await b.runtime.sessions.remove('s1') - // Persisted residue is gone with the session... - expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull() - // ...and a re-created same-id session starts from a FRESH instance. + expect(localStorage.getItem('dsh.conversation.s1')).toBeNull() + await b.runtime.sessions.add({ id: 's1' }) const reborn = storeFor(b, 'conversation.session', sid('s1')) expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null }) + expect(reborn.store.getSnapshot()).toEqual({ draft: '', view: null, viewRequest: null }) await b.runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 38522771c0..4633d07e20 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -5,15 +5,14 @@ // tag probe). import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import type { QueuedMessage, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' +import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' -async function bench(readAttachment?: SessionFace['readAttachment']) { +async function bench() { const runtime = await SlotTestRuntime.create() const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) @@ -21,7 +20,7 @@ async function bench(readAttachment?: SessionFace['readAttachment']) { const loadOlder = vi.fn(() => Promise.resolve()) await runtime.sessions.add({ id: 's1', - session: { prompt, updateQueue, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) }, + session: { prompt, updateQueue, cancel, loadOlder }, }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. @@ -115,27 +114,13 @@ describe('ConversationController', () => { await b.runtime.dispose() }) - it('invalidates pending historical image loads when the rendered session is released', async () => { - const read = Promise.withResolvers>>() - const b = await bench(() => read.promise) - const sessionId = b.runtime.sessions.behavior('s1').sessionId - const attachment = { - attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, - } as const - const pending = b.root.resolveImage(sessionId, attachment) - b.root.releaseSessionImages(sessionId) - read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) - await expect(pending).rejects.toThrow('historical image scope was released') - await b.runtime.dispose() - }) - - it('fails loudly from the root scope, on an unbound session, or without SessionRuntime', async () => { + it('fails loudly from the root scope, on an unbound session, or without Client Sessions', async () => { const b = await bench() await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/) await b.runtime.sessions.remove('s1') await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/) await b.runtime.dispose() - // No SessionRuntime at all: a bare context (the runtime always provides one). + // No Client Sessions service at all: a bare context lacks the assembled controller. const bare = new Context() await bare.plugin(ConversationController, { input: new InputHub(bare, makeTranslate(zh, {})), @@ -158,7 +143,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { it('steers every queued row in FIFO order and leaves steering rows alone', async () => { const b = await bench() - await b.runtime.sessions.updateSnapshot('s1', (draft) => { + await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')] }) b.shell.steerQueue() @@ -173,7 +158,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { it('converges silently when the turn closes or a row is claimed mid-steer', async () => { const b = await bench() - await b.runtime.sessions.updateSnapshot('s1', (draft) => { + await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.queue = [row('q-1'), row('q-2')] }) // The turn closes before the second row: the flush stops, silently. @@ -186,7 +171,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { // A row the host already claimed (e.g. a repeated empty-draft chord): // the duplicate strict steer is a silent no-op. - await b.runtime.sessions.updateSnapshot('s1', (draft) => { + await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.queue = [row('q-3')] }) b.updateQueue.mockResolvedValueOnce({ @@ -200,7 +185,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { it('surfaces one notice on a genuine steer failure and stops', async () => { const b = await bench() - await b.runtime.sessions.updateSnapshot('s1', (draft) => { + await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.queue = [row('q-1'), row('q-2')] }) b.updateQueue.mockResolvedValueOnce({ diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index ea80451b1d..620b0beedd 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -1,24 +1,28 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ReactNode } from 'react' +import type { ComponentProps, ReactNode } from 'react' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { Context } from '@deepseek-ai/cordis' +import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { - ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, makeTranslate, sessionSnapshot as sessionFixture, +} from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { createChatStore } from '../src/client/stores.ts' +import { EMPTY_CONVERSATION_SNAPSHOT } from '../src/client/contract/snapshot.ts' +import type { ConversationSnapshot } from '../src/client/contract/snapshot.ts' +import { createConversationStore } from '../src/client/stores.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { en, zh } from '../src/client/locales.ts' import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx' +import { conversationPhase } from '../src/client/contract/snapshot.ts' import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx' import type { HeroShellProps } from '../src/client/skeleton/EmptyHero.tsx' import { InputBar } from '../src/client/skeleton/InputBar.tsx' @@ -30,7 +34,7 @@ import type { ViewTab } from '../src/client/contract/views.ts' function fakeWiring() { const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) + const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) return { wiring: shell, sink, shell } } @@ -56,6 +60,11 @@ const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const SID = sid('s1') +type SessionSlotProps = ComponentProps + +const useChat: SessionSlotProps['useChat'] = () => { throw new Error('unused') } +const useTrajectory: SessionSlotProps['useTrajectory'] = () => { throw new Error('unused') } + function workspace(id = 'w1'): WorkspaceView { return { workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [], @@ -63,24 +72,16 @@ function workspace(id = 'w1'): WorkspaceView { } } -const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ +const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({ items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, }) -function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, - ...overrides, - } +function sessionSnapshotOf(overrides: Partial = {}): SessionSnapshot { + return { ...sessionFixture(SID), ...overrides } } function mount( - snapshot: ConversationSnapshot, + snapshot: SessionSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }], retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}), options: { @@ -125,11 +126,16 @@ function mount( current: SID, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore(workspaceState(workspaceRows)) - const session = createSnapshotStore(snapshot) + const workspaces = createSnapshotStore(workspaceState(workspaceRows)) + const session = createSnapshotStore(snapshot) const useSession = bindSnapshotSelector(session) - const chat = createChatStore().create() - chat.actions.setDraft('ordinary draft') + const conversation = createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT) + const useConversation = bindSnapshotSelector(conversation) + const useSessionPendingInteraction = bindSnapshotSelector( + createSnapshotStore(new Map()), + ) + const store = createConversationStore().create() + store.actions.setDraft('ordinary draft') const { wiring, sink } = fakeWiring() const useInput = bindSnapshotSelector(wiring.state) const inputActions = wiring.actions @@ -141,11 +147,7 @@ function mount( { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, ] - const views = { - list: () => viewTabs, - subscribe: () => () => {}, - version: () => 1, - } + const useConversationViews: SessionSlotProps['useConversationViews'] = selector => selector(viewTabs) /** Owner share handed to the two composer tool-row seats, per render. */ const seatOwners: { key: string; owner: unknown }[] = [] let pickerOwner: unknown @@ -163,17 +165,21 @@ function mount( return ( children(SID)} + SessionProvider={({ children }) => children} useSession={useSession} + useConversation={useConversation} + useConversationViews={useConversationViews} + useChat={useChat} + useTrajectory={useTrajectory} useSessions={props.useSessions} + useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} + useStore={bindSnapshotSelector(store)} + actions={store.actions} renderSlot={renderSlot as never} - views={views} open={open} t={t} /> @@ -183,18 +189,21 @@ function mount( return ( children(SID)} + SessionProvider={({ children }) => children} useSession={useSession} + useConversation={useConversation} + useConversationViews={useConversationViews} + useChat={useChat} + useTrajectory={useTrajectory} useSessions={props.useSessions} + useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} + useStore={bindSnapshotSelector(store)} + actions={store.actions} renderSlot={renderSlot as never} - views={views} - releaseSessionImages={vi.fn()} bindDraftMirror={write => wiring.bindMirror(write)} /> ) @@ -206,9 +215,11 @@ function mount( return ( children(SID)} + SessionProvider={({ children }) => children} useSession={useSession} + useConversation={useConversation} useSessions={props.useSessions} + useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={props.useWorkspaces} useProjection={(() => undefined)} useInput={useInput} @@ -251,9 +262,11 @@ function mount( )) as ConversationRootProps['renderSlotChain'] const props: ConversationRootProps = { sessionId: SID, - SessionProvider: ({ children }) => children(SID), + SessionProvider: ({ children }) => children, useSession, + useConversation, useSessions: bindSnapshotSelector(sessions), + useSessionPendingInteraction, useWorkspaces: bindSnapshotSelector(workspaces), useProjection: (() => undefined), useComposerBlock: select => select(options.composerBlock), @@ -266,7 +279,7 @@ function mount( } const view = render() return { - view, chat, sink, retargetWorkspace, session, slotCalls, lineageOwners, seatOwners, open, + view, store, sink, retargetWorkspace, session, conversation, slotCalls, lineageOwners, seatOwners, open, pickerOwner: () => pickerOwner, rerender: () => { view.rerender() }, } @@ -292,7 +305,7 @@ describe('Hero chrome', () => { describe('ConversationRoot resident composer', () => { it('renders the composer inert with the blocker\u2019s own reason', () => { - const b = mount(conversationSnapshot(), undefined, undefined, { + const b = mount(sessionSnapshotOf(), undefined, undefined, { composerBlock: { reason: 'select a model first' }, }) const box = b.view.getByRole('textbox') as HTMLTextAreaElement @@ -314,7 +327,7 @@ describe('ConversationRoot resident composer', () => { it('lets the no-workspace posture win over a block', () => { // Picking a workspace is the earlier prerequisite; naming a model first // would send the user somewhere they cannot act yet. - const b = mount(conversationSnapshot({ composerPhase: 'blank' }), [], undefined, { + const b = mount(sessionSnapshotOf({ blank: true }), [], undefined, { summaryBlank: true, composerBlock: { reason: 'select a model first' }, }) @@ -327,12 +340,12 @@ describe('ConversationRoot resident composer', () => { expect(modelSeat).toEqual({ locked: true }) }) - it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => { - const b = mount(conversationSnapshot()) + it('keeps composer text in the machine, mirrors to the Conversation store, and submits through the sink', () => { + const b = mount(sessionSnapshotOf()) const box = b.view.getByRole('textbox') expect((box as HTMLTextAreaElement).value).toBe('ordinary draft') fireEvent.change(box, { target: { value: 'ordinary revised' } }) - expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') + expect(b.store.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal)) expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) @@ -340,7 +353,7 @@ describe('ConversationRoot resident composer', () => { }) it('shows hierarchy only for subagents and opens their ordinary owner', () => { - const b = mount(conversationSnapshot(), undefined, undefined, { summaryOrigin: 'subagent' }) + const b = mount(sessionSnapshotOf(), undefined, undefined, { summaryOrigin: 'subagent' }) const root = b.view.getByRole('button', { name: 'Root' }) expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) fireEvent.click(root) @@ -348,7 +361,7 @@ describe('ConversationRoot resident composer', () => { }) it('keeps intermediate subagent breadcrumbs at the compact title size', () => { - const b = mount(conversationSnapshot(), undefined, undefined, { + const b = mount(sessionSnapshotOf(), undefined, undefined, { summaryOrigin: 'subagent', nestedSubagent: true, }) @@ -364,7 +377,7 @@ describe('ConversationRoot resident composer', () => { }) it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => { - const b = mount(conversationSnapshot()) + const b = mount(sessionSnapshotOf()) const host = b.view.container.querySelector('[data-conversation-scroll]') const seat = b.view.container.querySelector('[data-composer-seat]') const header = b.view.container.querySelector('header') @@ -382,7 +395,7 @@ describe('ConversationRoot resident composer', () => { }) it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => { - const b = mount(conversationSnapshot(), undefined, undefined, { overlayTakeover: true }) + const b = mount(sessionSnapshotOf(), undefined, undefined, { overlayTakeover: true }) const seat = b.view.container.querySelector('[data-composer-seat]') const takeover = b.view.getByTestId('composer-takeover') const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]') @@ -392,7 +405,7 @@ describe('ConversationRoot resident composer', () => { it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { const b = mount( - conversationSnapshot({ composerPhase: 'blank', blank: true }), + sessionSnapshotOf({ blank: true }), [ { ...workspace('one'), sessionIds: [SID] }, { ...workspace('second'), title: 'Selected Folder' }, @@ -409,11 +422,11 @@ describe('ConversationRoot resident composer', () => { expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the // persistence mirror stays bound (ConversationSession mounts chrome-hidden - // for blank sessions): hero typing reaches the chat store. + // for blank sessions): hero typing reaches the Conversation store. const box = b.view.getByRole('textbox') expect(host?.contains(box)).toBe(true) fireEvent.change(box, { target: { value: 'draft in hero' } }) - expect(b.chat.store.getSnapshot().draft).toBe('draft in hero') + expect(b.store.store.getSnapshot().draft).toBe('draft in hero') // Picker: open through the chip; a pick switches to the other // workspace's blank session (draft carry is apply-layer wiring). fireEvent.click(b.view.getByRole('button', { name: '选择工作区' })) @@ -424,8 +437,25 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByText('Selected Folder')).toBeTruthy() }) + it('keeps a rejected first prompt engaging instead of returning to the Hero', () => { + const failed = sessionSnapshotOf({ + blank: true, + promptAttempted: true, + awaitingFirstTurn: true, + promptError: { + op: 'send', + error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, + }, + }) + + expect(conversationPhase(failed, EMPTY_CONVERSATION_SNAPSHOT)).toBe('engaging') + const b = mount(failed, undefined, undefined, { summaryBlank: true }) + expect(b.view.container.querySelector('[data-phase]')?.getAttribute('data-phase')).toBe('active') + expect(b.view.queryByText('探索未至之境')).toBeNull() + }) + it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => { - const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' })) + const b = mount(sessionSnapshotOf({ blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') expect(b.view.queryByText('探索未至之境')).toBeNull() @@ -433,7 +463,7 @@ describe('ConversationRoot resident composer', () => { it('settling phase: a session the list has no row for settles conservatively', () => { const b = mount( - conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }), + sessionSnapshotOf({ blank: true, openState: 'loading' }), undefined, undefined, { omitSummaryRow: true }, @@ -444,7 +474,7 @@ describe('ConversationRoot resident composer', () => { it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => { const b = mount( - conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }), + sessionSnapshotOf({ blank: true, openState: 'loading' }), undefined, undefined, { summaryBlank: true }, @@ -458,39 +488,32 @@ describe('ConversationRoot resident composer', () => { }) it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => { - const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) + const b = mount(sessionSnapshotOf({ blank: true })) const before = b.view.getByRole('textbox') fireEvent.change(before, { target: { value: 'kept across flip' } }) // First message landed: content exists, phase leaves blank. Composer // already sat in the resident scrollport during hero, so the textarea // node and InputHub draft both survive. - b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false })) + b.session.set(sessionSnapshotOf({ blank: false })) b.rerender() const after = b.view.getByRole('textbox') as HTMLTextAreaElement expect(after).toBe(before) expect(after.value).toBe('kept across flip') - expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') + expect(b.store.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) expect(b.view.queryByText('探索未至之境')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) - it('keeps pending takeover interaction accessible outside the Chat view', () => { - const b = mount(conversationSnapshot({ pending: [{} as never] })) - act(() => { b.chat.actions.setView('trajectory') }) - expect(b.view.getByTestId('view-trajectory')).toBeTruthy() - expect(b.view.getByRole('textbox')).toBeTruthy() - }) - it('keeps the Chat fallback selected by id when a view is inserted before it', () => { const viewTabs: ViewTab[] = [ { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, ] - const b = mount(conversationSnapshot(), undefined, undefined, { viewTabs }) + const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs }) // A removed dynamic view leaves its persisted id behind. The visible // fallback is Chat and must stay Chat when another lower-order view lands. - act(() => { b.chat.actions.setView('removed-view') }) + act(() => { b.store.actions.setView('removed-view') }) expect(b.view.getByTestId('view-chat')).toBeTruthy() viewTabs.unshift({ id: 'new-view', label: 'New view' }) @@ -505,7 +528,7 @@ describe('ConversationRoot resident composer', () => { it('rolls the pending workspace label back when switching fails', async () => { const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') }) const b = mount( - conversationSnapshot({ composerPhase: 'blank', blank: true }), + sessionSnapshotOf({ blank: true }), [ { ...workspace('one'), sessionIds: [SID] }, { ...workspace('second'), title: 'Selected Folder' }, @@ -521,7 +544,7 @@ describe('ConversationRoot resident composer', () => { }) it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => { - const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) + const b = mount(sessionSnapshotOf({ blank: true })) const chip = b.view.getByRole('button', { name: '选择工作区' }) expect((chip as HTMLButtonElement).disabled).toBe(false) expect(b.slotCalls).toContain('conversation.hero.workspace') @@ -531,7 +554,7 @@ describe('ConversationRoot resident composer', () => { }) it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => { - const b = mount(conversationSnapshot({ + const b = mount(sessionSnapshotOf({ promptError: { op: 'send', error: { code: 'offline', message: 'Message send failed' } as never }, })) expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)') diff --git a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx index b2a2bb5dca..a1853ef0c7 100644 --- a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx @@ -7,8 +7,8 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx' diff --git a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx index 6f90658c5f..0f1c0045fa 100644 --- a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx +++ b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx @@ -1,11 +1,9 @@ -// View-ring type-chain samples. This spec pins the conversation-owned SlotMap -// row, list-kind registration shape, composed view props, and the runtime -// ledger projection consumed by ConversationRoot. +// Target-neutral View-ring type chain and runtime ledger projection. import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatViewSlotProps, ConvViewProps } from '../src/client/contract/slots.ts' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { ConvViewProps } from '../src/client/contract/slots.ts' describe('view-ring type negatives (compile-time; body never runs)', () => { it('holds the negative samples as expect-error sites', () => { @@ -31,24 +29,6 @@ describe('view-ring type negatives (compile-time; body never runs)', () => { return null } void renderless - // 5. The chat entry's face is its own: openDetails does not exist on the - // base view props (store-less riders never see it). - const baseOnly = (props: ConvViewProps): ReactNode => { - // @ts-expect-error openDetails lives on ChatViewSlotProps, not the base - void props.openDetails - return null - } - void baseOnly - // 6. ChatViewSlotProps carries the full composition (standard kit + - // store + inject face) — a handler with a wrong signature is red. - const chatProps = (props: ChatViewSlotProps): ReactNode => { - // @ts-expect-error openDetails takes a SelectionTarget, not a string - props.openDetails('nope') - // @ts-expect-error openFile takes a path string, not a SelectionTarget - void props.openFile({ turnSeq: 1, callId: 'c' }) - return null - } - void chatProps return null as ReactNode } expect(negatives).toBeTypeOf('function') diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index e3dcebfad7..a851104426 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -8,6 +8,15 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../api/workspace-controller/tsconfig.client.json" + }, { "path": "../../attachment/attachment" }, @@ -24,29 +33,35 @@ "path": "../ui-primitives" }, { - "path": "../runtime" + "path": "../store" }, { - "path": "../../core/agent" + "path": "../ui-layout" }, { - "path": "../../core/tools" + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../../core/session" }, { "path": "../../interaction/commands" }, { - "path": "../../session/session-projection" - }, - { - "path": "../../session/session-stats" - }, - { - "path": "../../llm/token-meter" + "path": "../../llm/llm" }, { "path": "../../llm/llm-retry" }, + { + "path": "../../workspace/workspace" + }, + { + "path": "../../llm/token-meter" + }, { "path": "../../plan/plan-mode" }, @@ -56,12 +71,6 @@ { "path": "../../todo/tool-todo" }, - { - "path": "../ui-input-trigger" - }, - { - "path": "../ui-layout" - }, { "path": "../locale" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 4035f4616c..30b6de4e7f 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -34,8 +34,9 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" } @@ -48,25 +49,29 @@ "peerDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^" + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index e882920378..6841e734e7 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,7 +1,7 @@ import { useLayoutEffect, useRef, useState } from 'react' import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ProducedFiles.module.css' diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 38b7f3fb89..879ed682e4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -8,9 +8,11 @@ * the owning view renders an empty chain and inert prose at zero cost. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' import { @@ -28,7 +30,7 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' export { producedForClosing } from './turn-deliverables.ts' /** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale', 'conversationEvents', 'connection'] +export const inject = ['slots', 'locale', 'uiConversation', 'connection'] /** * Client plugin body: register the dictionaries and the turn-tail entry. @@ -36,7 +38,7 @@ export const inject = ['slots', 'locale', 'conversationEvents', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - ctx.conversationEvents.register(deliverablesDefinition) + ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( 'conversation.chat.turnTail', diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index e63bca2e63..604ab099f1 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -3,12 +3,10 @@ * model-free: the vocabulary is the mutation tools' own follow-along * `locations`, never the closing prose. */ -import type { - ConversationNodeDefinition, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type { ToolResultNode, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' -import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' interface ProducedPath { readonly seq: number @@ -20,7 +18,7 @@ export interface DeliverablesTurnData { readonly produced: readonly ProducedPath[] } -declare module '@deepseek-ai/dsh-client-runtime/client' { +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationTurnDataMap { /** Successful mutation paths accumulated in this Turn. */ deliverables: DeliverablesTurnData diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index b37e65c9ea..7aef3b3580 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -9,15 +9,16 @@ import { Context } from '@deepseek-ai/cordis' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { - ConversationEventRegistry, ConversationNodeAssembler, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationNodeAssembler, UiConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, - ConversationViewNode, ToolResultNode, TurnLocation, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationViewNode, TurnLocation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { fitProducedFiles, ProducedFiles, type ProducedFilesProps, @@ -115,7 +116,7 @@ function at( seq, time: seq * 1_000, type, data, ...(type === 'tool/result' ? { surfaceOp: 'append' } : {}), } as ConversationEventInput['event'], - view, + ...(view === undefined ? {} : { view }), } } @@ -123,10 +124,12 @@ function matched(input: ConversationEventInput, role: ConversationMatch['role']) return { ...input, role, location: { kind: 'unresolved' } } } +type WireCallView = Extract, { for: 'call' }>['view'] + function call( seq: number, callId: string, - view: ToolResultNode['callView'], + view: WireCallView | null, turn = 1, ): ConversationEventInput { return at( @@ -148,7 +151,7 @@ function result(seq: number, callId: string, isError = false, turn = 1): Convers }) } -function diff(...paths: string[]): ToolResultNode['callView'] { +function diff(...paths: string[]): WireCallView { return { card: 'diff', title: `Write ${paths[0] ?? ''}`, diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), @@ -156,7 +159,7 @@ function diff(...paths: string[]): ToolResultNode['callView'] { } } -function edit(path: string): ToolResultNode['callView'] { +function edit(path: string): WireCallView { return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] } } @@ -454,7 +457,7 @@ describe('plugin registration', () => { it('registers the tail entry and fiber disposal removes it', async () => { const ctx = new Context() await ctx.plugin(SlotRegistry).await() - await ctx.plugin(ConversationEventRegistry).await() + new UiConversation(ctx, { binding: () => undefined } as never) // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index 7074624096..51e6d50b5d 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/packages/client/ui-deliverables/tsconfig.json @@ -11,14 +11,23 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../locale" }, { - "path": "../runtime" + "path": "../ui-conversation" }, { - "path": "../ui-conversation" + "path": "../ui-chat" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-primitives" }, { "path": "../ui-slots" @@ -28,6 +37,9 @@ }, { "path": "../../core/system-prompt" + }, + { + "path": "../../core/session" } ] } diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 3f1ef97ed3..1bd81078ab 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -31,8 +31,12 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-client-ui-workspace/client" + ], "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-client-locale" ], @@ -48,17 +52,19 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index ca64849943..3266bd88af 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -40,8 +40,8 @@ import { Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' -import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import css from './DirectoryBrowser.module.css' diff --git a/packages/client/ui-directory-picker-browse/src/client/flow.ts b/packages/client/ui-directory-picker-browse/src/client/flow.ts index 84e49b2c98..dbe80e9415 100644 --- a/packages/client/ui-directory-picker-browse/src/client/flow.ts +++ b/packages/client/ui-directory-picker-browse/src/client/flow.ts @@ -5,7 +5,7 @@ */ import { createElement } from 'react' import type { ReactElement } from 'react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' // Type-only: the owner contract of the directory-flow holes. import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' diff --git a/packages/client/ui-directory-picker-browse/src/client/index.ts b/packages/client/ui-directory-picker-browse/src/client/index.ts index 48f9c14309..8730d48098 100644 --- a/packages/client/ui-directory-picker-browse/src/client/index.ts +++ b/packages/client/ui-directory-picker-browse/src/client/index.ts @@ -7,17 +7,19 @@ * cordis.yml row; no client code branches on a capability kind. The dialog's * copy is locale-registered here — the flow package owns its own strings. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { BrowseFlowInjected } from './flow.ts' import { BrowseDirectoryFlow } from './flow.ts' /** Locale namespace owning the browser dialog's copy. */ const LOCALE_NS = 'directory-browser' -/** Required services (cordis fiber inject): the slot registry, the wire-facing workspace service, and locale. */ -export const inject = ['slots', 'workspaces', 'locale'] +/** Required services (cordis fiber inject): the slot registry, workspace UI service, and locale. */ +export const inject = ['slots', 'uiWorkspace', 'locale'] /** * Client plugin body: register the dialog's dictionaries and the browse flow @@ -73,8 +75,8 @@ export function apply(ctx: ClientContext): void { }, 'directory-picker-browse: dialog dictionaries') const injected = (): BrowseFlowInjected => ({ - listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal), - createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), + listDirectory: (path, signal) => ctx.uiWorkspace.listDirectory(path, signal), + createDirectory: (path, name) => ctx.uiWorkspace.createDirectory(path, name), t: ctx.locale.bind(LOCALE_NS), }) // Both declaration lifetimes must be live before the pair installs; the diff --git a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx index 5cf7043b65..6008e58ddc 100644 --- a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx @@ -2,8 +2,8 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -34,7 +34,7 @@ async function bench() { ctx.provide('locale', new LocaleRuntime(ctx)) const listDirectory = vi.fn(async (): Promise => homeListing) const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`) - ctx.provide('workspaces', { listDirectory, createDirectory } as never) + ctx.provide('uiWorkspace', { listDirectory, createDirectory } as never) const slots = ctx.get('slots') as SlotRegistry const declare = () => slots.register({ name: 'root', @@ -53,7 +53,7 @@ function owner(overrides: Partial = {}): DirectoryFlowO describe('directory-picker-browse client half', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'workspaces', 'locale']) + expect(inject).toEqual(['slots', 'uiWorkspace', 'locale']) }) it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { diff --git a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx index 584f791290..6c1d35a76b 100644 --- a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' -import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-ui-workspace/client' import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' afterEach(cleanup) diff --git a/packages/client/ui-directory-picker-browse/tsconfig.json b/packages/client/ui-directory-picker-browse/tsconfig.json index c229f13228..24535dac3f 100644 --- a/packages/client/ui-directory-picker-browse/tsconfig.json +++ b/packages/client/ui-directory-picker-browse/tsconfig.json @@ -17,11 +17,14 @@ { "path": "../ui-primitives" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../locale" }, { - "path": "../runtime" + "path": "../ui-renderer" }, { "path": "../ui-workspace" diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 48e91703b1..986e38be54 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-workspace" ], "platform": "web" @@ -44,13 +44,13 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index af220fe5ba..951efc2356 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -7,15 +7,17 @@ * both sides of the native interaction with one cordis.yml row; no client * code branches on a capability kind. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { NativeFlowInjected } from './flow.ts' import { NativeDirectoryFlow } from './flow.ts' -/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */ -export const inject = ['slots', 'workspaces'] +/** Required services (cordis fiber inject): the slot registry and workspace UI service. */ +export const inject = ['slots', 'uiWorkspace'] /** * Client plugin body: register the renderless native flow into both @@ -24,7 +26,7 @@ export const inject = ['slots', 'workspaces'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() }) + const injected = (): NativeFlowInjected => ({ pick: () => ctx.uiWorkspace.pickDirectory() }) // Both declaration lifetimes must be live before the pair installs; the // generator makes the two registrations one transactional effect. The // outer/inner nesting order is arbitrary; neither hole has precedence. diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 39d851610a..2cfd1350ca 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' @@ -17,7 +17,7 @@ async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const pickDirectory = vi.fn(async (): Promise => '/tmp/picked') - ctx.provide('workspaces', { pickDirectory } as never) + ctx.provide('uiWorkspace', { pickDirectory } as never) const slots = ctx.get('slots') as SlotRegistry const declare = () => slots.register({ name: 'root', @@ -36,7 +36,7 @@ function owner(overrides: Partial = {}): DirectoryFlowO describe('directory-picker-native client half', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'workspaces']) + expect(inject).toEqual(['slots', 'uiWorkspace']) }) it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { diff --git a/packages/client/ui-directory-picker-native/tsconfig.json b/packages/client/ui-directory-picker-native/tsconfig.json index c5dc5ef93d..75e7cfbb4e 100644 --- a/packages/client/ui-directory-picker-native/tsconfig.json +++ b/packages/client/ui-directory-picker-native/tsconfig.json @@ -15,7 +15,7 @@ "path": "../ui-slots" }, { - "path": "../runtime" + "path": "../ui-renderer" }, { "path": "../ui-workspace" diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 27ea4a076c..a14e25110e 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -32,10 +32,13 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } @@ -46,10 +49,13 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -58,13 +64,16 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-goal/src/client/goal-command-input.ts b/packages/client/ui-goal/src/client/goal-command-input.ts index 7d58a5305d..526dc0958d 100644 --- a/packages/client/ui-goal/src/client/goal-command-input.ts +++ b/packages/client/ui-goal/src/client/goal-command-input.ts @@ -3,7 +3,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type {} from '@deepseek-ai/dsh-commands/types' import type { ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** Goal-owned human command input projected independently of model messages. */ export interface GoalCommandInputData { @@ -12,7 +12,7 @@ export interface GoalCommandInputData { readonly time: number } -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '@deepseek-ai/dsh-client-ui-chat/client' { interface ChatNodeDataMap { /** Human-entered `/goal` command input. */ 'command-input': GoalCommandInputData diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index bfa1a283b5..47286c07a9 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -8,13 +8,22 @@ * their CAS ref reads the session's current projected value at call time. * Goal creation stays on the /goal host command. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' -// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). +// Type-only: pulls the Session Controller service used for projected goal state. +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +// Type-only: pulls the Chat node slot and its keyed data map. +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +// Type-only: pulls the Conversation service and input-dock slot. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the renderer-owned slots service. +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: pulls the Session standard useProjection seat. +import type {} from '@deepseek-ai/dsh-client-ui-session/client' // Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet). import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client' import type { GoalActionResult, GoalBarActions } from './slots.ts' @@ -38,14 +47,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const NS = 'goal' /** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ -export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents'] +export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'uiConversation'] /** * Client plugin body: the GoalBar dock entry with its mutation verbs. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.conversationEvents.register(goalCommandInputDefinition) + ctx.uiConversation.events.register(goalCommandInputDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ diff --git a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx index 32b3ccd9cf..cb2cf0a4be 100644 --- a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx @@ -14,8 +14,9 @@ import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' -import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-runtime/src/client/conversation/event-registry.ts' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -52,7 +53,18 @@ async function bench(options: { } = {}) { const ctx = new Context() const calls: { method: string; args: unknown[] }[] = [] - const conversationEvents = new ConversationEventRegistry(ctx) + const sessions = { + binding: (id: SessionId) => ({ + sessionId: id, + session: { projections: { faceOf: (key: string) => ({ + getSnapshot: () => (key === 'goal' ? options.projection : undefined), + subscribe: () => () => {}, + }) } }, + ctx, + }), + } + ctx.provide('sessions', sessions) + const conversationEvents = new UiConversation(ctx, sessions as never).events function answer(method: string, value: T) { return (...args: unknown[]) => { calls.push({ method, args }) @@ -88,16 +100,6 @@ async function bench(options: { }, } as never, (() => null) as never) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('sessions', { - binding: (id: SessionId) => ({ - sessionId: id, - session: { projections: { faceOf: (key: string) => ({ - getSnapshot: () => (key === 'goal' ? options.projection : undefined), - subscribe: () => () => {}, - }) } }, - ctx, - }), - }) const fiber = ctx.plugin({ inject: [...inject], apply }) return { ctx, diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index c867e516d6..811e922118 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -2,15 +2,17 @@ import { cleanup, render, within } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import type { - ChatConversationViewNode, ChatSnapshot, ConversationEventInput, - ConversationNodeDefinition, ConversationViewDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' + ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ChatConversationViewNode, ChatSnapshot, +} from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts' -import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts' +import { commandDefinition } from '@deepseek-ai/dsh-client-ui-chat/src/client/conversation-nodes/command.ts' +import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts' import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx' import { goalCommandInputDefinition, goalCommandText, @@ -38,7 +40,6 @@ class TestViewDefinitions { function entry(seq: number, type: string, data: unknown): ConversationEventInput { return { event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'], - view: undefined, } } diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 0a6c80a0af..0e81d8e171 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -18,11 +18,20 @@ "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../runtime" + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../ui-chat" }, { "path": "../ui-conversation" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-primitives" }, @@ -35,6 +44,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../typert/protocol" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 9916dc1de7..4489bbe5af 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -31,9 +31,14 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-locale" + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" } @@ -48,14 +53,19 @@ }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^" + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -63,7 +73,11 @@ "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "@deepseek-ai/dsh-file-reference": "workspace:^" + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-input-trigger/src/client/contract.ts b/packages/client/ui-input-trigger/src/client/contract.ts index 887ef1cb18..291844247a 100644 --- a/packages/client/ui-input-trigger/src/client/contract.ts +++ b/packages/client/ui-input-trigger/src/client/contract.ts @@ -4,7 +4,7 @@ * see registerSource alone, the conversation wiring layer resolves its * per-session controller through sessionOf. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { InputTriggerSource } from '../types.ts' import type { InputTriggerController } from './controller.ts' diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 68bb5a15cc..b2507cfc12 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -7,8 +7,9 @@ * only the source roster. One controller per session scope; the service * disposes it with the scope fiber. */ -import type { ClientContext, SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { detectTrigger } from '../core/detect.ts' import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts' import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts' diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index 0e7f1af099..64f48ea64a 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -6,7 +6,10 @@ */ // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { InputTriggerService } from './service.ts' import type { MenuViewInjected } from './slots.ts' import { MenuView } from './MenuView.tsx' @@ -57,7 +60,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-input-trigger: menu dictionaries') ctx.inject(['slots', 'inputTriggers', 'sessions'], (scope: ClientContext) => { const inputTriggers = scope.inputTriggers - const sessions = scope.sessions + const sessions = resolveClientSessions(scope) scope.slots.inject('conversation.input.overlay', () => scope.slots.register({ name: 'conversation.input.overlay', id: 'slash-menu', diff --git a/packages/client/ui-input-trigger/src/client/service.ts b/packages/client/ui-input-trigger/src/client/service.ts index 93640dabae..a22627b398 100644 --- a/packages/client/ui-input-trigger/src/client/service.ts +++ b/packages/client/ui-input-trigger/src/client/service.ts @@ -7,7 +7,9 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerSource } from '../types.ts' import { InputTriggerController } from './controller.ts' import type { InputTriggerServiceContract } from './contract.ts' diff --git a/packages/client/ui-input-trigger/src/client/slots.ts b/packages/client/ui-input-trigger/src/client/slots.ts index 5c1f2e3182..fd85856961 100644 --- a/packages/client/ui-input-trigger/src/client/slots.ts +++ b/packages/client/ui-input-trigger/src/client/slots.ts @@ -1,30 +1,8 @@ -/** - * Overlay-slot contract surface of the slash plugin. The - * 'conversation.input.overlay' slot is OWNED by the ui-conversation composer - * entry (declaring is claiming: anchor, children declaration, lifecycle), - * but the SlotMap type merge lives here: the owner package depends on this - * one, so the dependency direction admits no reverse type import, and a - * type-erased registration is ruled out. The owner's - * program picks this merge up transitively through its ui-input-trigger imports. - */ -// Type-only edge: the SlotMap augmentation below merges into this package's interface. -import type {} from '@deepseek-ai/dsh-client-ui-slots' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +/** Slash-menu props for the Conversation-owned input overlay. */ +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { MenuState } from '../core/contract.ts' -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface SlotMap { - /** - * The InputBar floating overlay anchor: MenuView (this package) and the - * popupSelect shell (ui-commands) contribute list entries; each reads its - * own store and renders null while closed. Declared (children table) by - * ui-conversation's composer entry; the anchor hides with the input - * under a takeover. - */ - 'conversation.input.overlay': { kind: 'list'; scope: 'session' } - } -} - /** Injected business face of the MenuView overlay entry (copy rides the standard locale seat, not this face). */ export interface MenuViewInjected { /** The service's menu state store (read-only here; MenuView subscribes). */ diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 5f2dd6dfe6..025beadd09 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -1,14 +1,22 @@ /** - * Frozen cross-package contract for the input trigger pipeline. Types only — - * no runtime code. Sources (ui-commands / ui-skill / ui-reference) and the - * conversation input layer import from here; changes require main-thread - * arbitration. + * Input-trigger provider contract. Types only — no runtime code. The + * conversation input layer owns and exports the shared machine currency; + * this module re-exports it for trigger providers. * * Providers receive a {@link ClientSessionContext} projection per call — * never a Cordis context or the mutable Session. RPC and service access go * through the provider plugin's own root context captured at registration. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + PickOutcome, TokenSpan, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +export type { + ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, + InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitImageAttachment, + SubmitOutcome, TokenSpan, +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** * The provider-facing projection of one client session. It carries stable @@ -41,85 +49,6 @@ export interface InputTriggerCandidate { readonly value?: string } -/** Pick-moment snapshot of the trigger token span. CAS: stale draftRev ⇒ the whole action no-ops. */ -export interface TokenSpan { - readonly start: number - readonly end: number - readonly draftRev: number -} - -/** Base64-encoded composer image accompanying one claimed submit transaction. */ -export interface SubmitImageAttachment { - /** Declared media type; the host verifies it against the decoded bytes. */ - readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' - /** Canonical base64 encoding of the image bytes. */ - readonly data: string - /** Optional display name; never interpreted as a path. */ - readonly name?: string -} - -/** - * Command-mode entry credential. Pure data + a closure method — no class, no - * cross-package runtime value (client bundle purity). - */ -export interface CommandClaim { - /** Integrity-watched draft prefix, e.g. `'/goal '` — breaking startsWith releases the claim. */ - readonly token: string - /** Ghost-text hint rendered while the claim's args are blank. */ - readonly hint?: string - /** - * Whether composer image attachments may accompany this command's submit. - * Absent = the composer refuses to submit while images are attached, keeping - * the draft and the images in place behind a visible notice. - */ - readonly images?: boolean - /** - * Enter transaction, supplied by the source as a closure. - * @param images - serialized composer images accompanying the submission; - * the composer passes them only when {@link CommandClaim.images} is true. - */ - submit(args: string, actx: ClientContext, images: readonly SubmitImageAttachment[]): Promise -} - -/** - * Inline reference insertion. The draft holds the complete display text while - * the occurrence retains its range; the owner supplies both user-facing projections at insert time - * (the model representation is serialized on submit via the source codec). - */ -export interface ReferenceInsert { - readonly source: string - readonly ref: string - /** Inline display label (fallback-cached on the occurrence). */ - readonly label: string - /** Optional domain glyph shown beside the label. */ - readonly appearance?: 'session' | 'file' | 'folder' - /** Clipboard / persistence projection, e.g. `/name` (never the model form). */ - readonly clipboardText: string -} - -/** Settled result of a command submit transaction. */ -export interface SubmitOutcome { - readonly kind: 'success' | 'error' - readonly text?: string -} - -/** - * Unified pick return. `undefined` = miss → default sink; `'handled'` = the - * source dealt with it internally (e.g. opened its popup shell). The `text` - * arm is the plain-text reference path (decision recorded in - * .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): - * the token span is - * replaced with literal text — no occurrence identity, no placeholder; any - * chip visual is derived downstream by scanning the draft against the - * source lexicons. - */ -export type PickOutcome = - | { readonly claim: CommandClaim } - | { readonly insert: ReferenceInsert } - | { readonly text: string; readonly continue?: boolean } - | 'handled' - | undefined - /** * Non-text composer submission state visible to enter adjudication. The * composer owns the actual attachment payloads; adjudication only needs their @@ -235,73 +164,3 @@ export interface TriggerGuard { /** plain: '/' and '@' live; claimed: '/' suppressed, '@' live; frozen: none. */ readonly tier: 'plain' | 'claimed' | 'frozen' } - -/** Keys the menu intercepts while open (all behind the IME composition guard). */ -export type ArbitrateKey = 'up' | 'down' | 'enter' | 'escape' - -/** consumed = key handled; pick-highlighted = enter picked the highlight; pass = let the input see it. */ -export type ArbitrateOutcome = 'consumed' | 'pick-highlighted' | 'pass' - -/** Request payload of the scoped begin-command input event. */ -export interface BeginCommandRequest { - readonly claim: CommandClaim - readonly span: TokenSpan -} - -/** Request payload of the scoped insert-reference input event. */ -export interface InsertReferenceRequest { - readonly reference: ReferenceInsert - readonly span: TokenSpan -} - -/** Request payload of the scoped consume-token input event. */ -export interface ConsumeTokenRequest { - readonly guard: - | { readonly kind: 'span'; readonly span: TokenSpan } - | { readonly kind: 'bare-token'; readonly token: string } -} - -/** Request payload of the scoped insert-text input event (the plain-text reference path). */ -export interface InsertTextRequest { - /** Literal replacement for the trigger token span (e.g. `/name `). */ - readonly text: string - readonly span: TokenSpan - /** Keep completion open after the splice (directory descent): the input re-tracks at the caret. */ - readonly continue?: boolean -} - -declare module '@deepseek-ai/cordis' { - interface Events { - /** - * Applies one command claim to the scoped Input. Dispatched with the - * session's scope carrier; the owning session's input listener returns - * `true` only after the phase and span CAS checks pass and the machine - * actually mutated — producers treat anything else as "not applied". - * @param request - Claim and menu-time span CAS. - * @mode bail - */ - 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined - /** - * Inserts one reference into the scoped Input (same carrier routing and - * applied-truth contract as begin-command). - * @param request - Reference and menu-time span CAS. - * @mode bail - */ - 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined - /** - * Consumes one command token after business success (popup settle / - * menu-pick execute). Same carrier routing and applied-truth contract. - * @param request - Exact span or bare-token guard. - * @mode bail - */ - 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined - /** - * Replaces the trigger token span with literal text — the plain-text - * reference path. Same carrier routing and applied-truth - * contract; the draft gains ordinary characters, no occurrence entry. - * @param request - Replacement text and menu-time span CAS. - * @mode bail - */ - 'slash/input-insert-text'(request: InsertTextRequest): true | undefined - } -} diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index e66fb73262..43f1012993 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -7,8 +7,11 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { + createScope, resolveClientSessions, scopeOf, +} from '@deepseek-ai/dsh-api-session-controller/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -77,7 +80,7 @@ describe('apply', () => { const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected const injected = injectEntry(sid('a')) const controller = inputTriggers.sessionOf( - (ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')), + resolveClientSessions(ctx).scope(sid('a'))!, ) expect(injected.menu).toBe(controller.menu) // The pick face routes into the controller pipeline (closed menu → no-op). diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index 5412d2d7e7..848aaa033d 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -9,7 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index a03839d7bd..b936e64c5f 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -9,8 +9,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { InputTriggerController, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { BeginCommandRequest, ClientSessionContext, CommandClaim, InsertReferenceRequest, PickOutcome, diff --git a/packages/client/ui-input-trigger/tsconfig.json b/packages/client/ui-input-trigger/tsconfig.json index 0f479a45f4..1570df8e63 100644 --- a/packages/client/ui-input-trigger/tsconfig.json +++ b/packages/client/ui-input-trigger/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -15,14 +18,26 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../../context/file-reference" }, + { + "path": "../../core/session" + }, { "path": "../ui-primitives" }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 2767bc42f9..23ebb49d68 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md -README.md: e8c1852aca0b7fb375b3c58748a9f37385e8ebee -README.zh.md: b9f980283a7cbddce3c8a4de07079e5fc3b61212 +README.md: 0523aa10ebfbaa6cde0bef391017bc0307d8bdd5 +README.zh.md: bb713f446855a9e8b23c5543827b9897f1177a48 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index e8c1852aca..0523aa10eb 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web background-job feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.jobs` records this session can see. The data arrives entirely through the `jobsBySession` list mirror that [`dsh-client-runtime`](../runtime/README.md) folds from `session/jobs` frames, so this package issues no RPC and holds no state beyond popover visibility. +Web background-job feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.jobs` records this session can see. The data arrives entirely through the `jobsBySession` list mirror that the [Session Controller](../../api/session-controller/README.md) folds from `session/jobs` frames, so this package issues no RPC and holds no state beyond popover visibility. The trigger renders only when the session has at least one job, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished jobs a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index b9f980283a..bb713f4468 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.jobs` 记录。数据完全来自 [`dsh-client-runtime`](../runtime/README.zh.md) 从 `session/jobs` 帧折叠出的 `jobsBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。 +Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.jobs` 记录。数据完全来自 [Session Controller](../../api/session-controller/README.zh.md) 从 `session/jobs` 帧折叠出的 `jobsBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。 只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running` 加 `stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 5f3b41460f..a195c70e08 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -25,7 +25,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives" ], @@ -47,14 +46,15 @@ }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -62,7 +62,10 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 8834abf82b..bf15074079 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { JobView } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' import { IconChevronDownOutline14, StateDot, useDismissOnOutsidePointer, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 658f080702..22dc2c8425 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -4,9 +4,11 @@ * through the `jobsBySession` list mirror, so the plugin issues no RPC and * holds no state of its own beyond popover visibility. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import { JobListAction } from './JobListAction.tsx' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { en, NS, zh, type JobKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index 18e19d7e40..93e6df1e5f 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -7,7 +7,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' @@ -93,7 +93,9 @@ describe('ui-job invariant companion', () => { expect(JobInvariant.name).toBe('client-ui-jobs-invariant') expect(JobInvariant.inject).toEqual(['invariants']) // Emitting an unrelated event proves the companion installed no audit. - expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow() + expect(() => { + Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) + }).not.toThrow() await fiber.dispose() }) }) diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index 399ad4aa1c..c3f016d58e 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionId, SessionListState, JobView } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' import { zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-jobs/tsconfig.json b/packages/client/ui-jobs/tsconfig.json index fc376a313a..727523a055 100644 --- a/packages/client/ui-jobs/tsconfig.json +++ b/packages/client/ui-jobs/tsconfig.json @@ -8,21 +8,27 @@ "src" ], "references": [ + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, { "path": "../locale" }, - { - "path": "../runtime" - }, { "path": "../ui-conversation" }, { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 3b2bf0da97..b143308743 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -32,7 +32,8 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-theme" ], "platform": "web" @@ -44,14 +45,17 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 2696dc91fa..efca05a73c 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -14,6 +14,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import type { ReactNode } from 'react' import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { computeColumns, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts' +import { DocumentTitle } from './DocumentTitle.tsx' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' @@ -89,12 +90,17 @@ export function AppFrame({ useSessions, actions, renderSlot, + SessionProvider, }: AppFrameProps) { const panels = useStore(s => s) const detailsSession = useSessions((s) => { const current = s.current return current !== undefined && s.byId[current]?.blank === false ? current : undefined }) + const documentTitle = useSessions((s) => { + const current = s.current + return current === undefined ? undefined : s.byId[current]?.title + }) const frameRef = useRef(null) const [viewport, setViewport] = useState(() => window.innerWidth) @@ -170,6 +176,7 @@ export function AppFrame({ data-details-collapsed={cols.details === 0 || undefined} data-dragging={dragging || undefined} > +
{/* Render-site slot call with live concession output: a closed sidebar keeps the mounted slot at the compact-rail width, and the @@ -185,10 +192,12 @@ export function AppFrame({ {/* Both column occupants stay at fixed tree positions from first paint — no loading gate: a bare status line reads worse than the shell's own pending rendering. The conversation - is session-maybe; the strict details entry naturally renders - empty while no session is current. */} + is session-maybe; SessionProvider withholds the strict details + entry while no session is current. */} {renderSlot('conversation', {})} - {renderSlot('details', {})} + + {renderSlot('details', {})} +
{renderSlot('shell.overlay', {})} diff --git a/packages/client/ui-renderer/src/client/DocumentTitle.tsx b/packages/client/ui-layout/src/client/DocumentTitle.tsx similarity index 100% rename from packages/client/ui-renderer/src/client/DocumentTitle.tsx rename to packages/client/ui-layout/src/client/DocumentTitle.tsx diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index c56d83bfbb..cecf06deb8 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -7,7 +7,9 @@ * with the runtime sessions service. A second effect seats the theme * presenter, which projects ctx.theme snapshots onto document.body. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import type { PanelActions } from './service.ts' import { AppFrame } from './AppFrame.tsx' diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index d2de668a9c..7b6e2807ee 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -7,7 +7,7 @@ * derives its PropsStore share from the return type, and the service face * receives the bound actions through the registration's inject hook. */ -import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' import { clampWidth, DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index d865a5c186..fe55ca922e 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -2,7 +2,7 @@ /** * AppFrame interaction spec under the four-share props form: real layout * store instance (createLayoutStore().create() — the test-sanctioned engine - * path), a recording renderSlot stub, and a render-prop SessionProvider stub + * path), a recording renderSlot stub, and a SessionProvider component stub * (the real one is framework-wired to the renderer host; its own behavior is * ui-renderer's spec territory). Drag sequences (pointer capture + rAF flush), * concession response to viewport change, and details staying mounted at @@ -17,22 +17,24 @@ import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame. import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx' import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts' import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts' -import type { - SessionId, SessionListState, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Session selection controls for the SessionProvider and useSessions stubs. const selectedSession = { current: 's-test' as SessionId | undefined } const selectedSessionBlank = { current: false } -const baselinesReady = { current: true } +const selectedSessionTitle = { current: undefined as string | undefined } +const workspacesReady = { current: true } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention) -// Render-prop contract stub fed through the standard seat prop (the renderer -// injects the real one in production): session mode runs children(id), empty -// mode runs the empty branch — the frame must work against exactly this -// shape. Typed as the seat's own component type so the branded sessionId -// parameter stays contract-checked. +// Provider contract stub fed through the standard seat prop (the renderer +// injects the real one in production): session mode renders children and +// empty mode runs the empty branch. const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) => - selectedSession.current === undefined ? <>{empty?.() ?? null} : <>{children(selectedSession.current)} + selectedSession.current === undefined ? <>{empty?.() ?? null} : <>{children} /** Observer stub: captures the callback so tests can fire resizes manually. */ @@ -70,15 +72,24 @@ function mountFrame() { ids: current === undefined ? [] : [current], byId: current === undefined ? {} - : { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } }, + : { + [current]: { + id: current, + displayTitle: 'Test', + running: false, + blank: selectedSessionBlank.current, + updatedAt: 1, + ...(selectedSessionTitle.current === undefined ? {} : { title: selectedSessionTitle.current }), + }, + }, current, phase: 'ready', } as SessionListState return sel(sessionState) }) as never - const workspaceState: WorkspaceListState = { + const workspaceState: WorkspaceSnapshot = { items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: baselinesReady.current, recentWorkspaceId: undefined, + ...(workspacesReady.current ? {} : { state: 'loading' as const, phase: 'pending' as const }), } const element = () => ( unknown) => sel(workspaceState)) as never} + useSessionPendingInteraction={useSessionPendingInteraction} + useWorkspaces={((sel: (s: WorkspaceSnapshot) => unknown) => sel(workspaceState)) as never} SessionProvider={SessionProviderStub} /> ) @@ -114,7 +126,8 @@ beforeEach(() => { frameWidth = 1920 selectedSession.current = 's-test' as SessionId selectedSessionBlank.current = false - baselinesReady.current = true + selectedSessionTitle.current = undefined + workspacesReady.current = true vi.useFakeTimers() vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number) @@ -132,11 +145,28 @@ beforeEach(() => { afterEach(() => { cleanup() + document.title = '' vi.useRealTimers() vi.unstubAllGlobals() + vi.unstubAllEnvs() }) describe('AppFrame', () => { + it('projects the selected durable Session title', () => { + vi.stubEnv('DSH_CLIENT_TITLE', 'Product') + selectedSessionTitle.current = 'First' + const { rerenderFrame } = mountFrame() + expect(document.title).toBe('First — Product') + + selectedSessionTitle.current = 'Revised' + act(() => { rerenderFrame() }) + expect(document.title).toBe('Revised — Product') + + selectedSession.current = undefined + act(() => { rerenderFrame() }) + expect(document.title).toBe('Product') + }) + it('renders three tracks from store state', () => { const { frame } = mountFrame() expect(tracks(frame)).toEqual([280, 0]) @@ -158,15 +188,17 @@ describe('AppFrame', () => { // No current session: the session-maybe conversation shell owns the New // Session view itself — the center column renders it unconditionally. selectedSession.current = undefined - const { slotCalls, getByTestId } = mountFrame() + const { slotCalls, getByTestId, queryByTestId } = mountFrame() expect(getByTestId('center-content')).toBeTruthy() expect(slotCalls.map(c => c.key)).toContain('conversation') + expect(queryByTestId('details-content')).toBeNull() + expect(slotCalls.map(c => c.key)).toContain('details') }) it('renders both column occupants before baselines settle (no loading gate)', () => { // No loading gate: a bare loading status reads worse than the shell's own // pending rendering — both occupants mount from first paint. - baselinesReady.current = false + workspacesReady.current = false const { slotCalls } = mountFrame() expect(slotCalls.map(c => c.key)).toContain('conversation') expect(slotCalls.map(c => c.key)).toContain('details') diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index f8f4dd3c72..d45a62c046 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client' @@ -94,7 +94,7 @@ describe('ui-layout client apply', () => { expect(ctx.get('layout')).toBeUndefined() expect(slots.entries('root')).toHaveLength(0) expect(slots.spec('sidebar')).toBeUndefined() - // The built-in root declaration survives entry teardown (runtime-owned). + // The built-in root declaration survives entry teardown (renderer-owned). expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' }) }) }) diff --git a/packages/client/ui-renderer/tests/document-title.client.spec.tsx b/packages/client/ui-layout/tests/document-title.client.spec.tsx similarity index 100% rename from packages/client/ui-renderer/tests/document-title.client.spec.tsx rename to packages/client/ui-layout/tests/document-title.client.spec.tsx diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 23da1ac7c2..ba35af9f86 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -24,7 +24,13 @@ "path": "../ui-primitives" }, { - "path": "../runtime" + "path": "../store" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" }, { "path": "../../runtime-diagnostics/invariants" diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 14034b8478..19df358147 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -32,10 +32,10 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer" ], "platform": "web" } @@ -49,31 +49,37 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-message-feedback/src/client/controller.ts b/packages/client/ui-message-feedback/src/client/controller.ts index 92b7c4dda7..29bbfede8d 100644 --- a/packages/client/ui-message-feedback/src/client/controller.ts +++ b/packages/client/ui-message-feedback/src/client/controller.ts @@ -9,7 +9,8 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { MessageFeedbackDeleteResult, MessageFeedbackItem, diff --git a/packages/client/ui-message-feedback/src/client/index.ts b/packages/client/ui-message-feedback/src/client/index.ts index d602ac2bb0..8f88632ec4 100644 --- a/packages/client/ui-message-feedback/src/client/index.ts +++ b/packages/client/ui-message-feedback/src/client/index.ts @@ -7,13 +7,18 @@ * @module @deepseek-ai/dsh-client-ui-message-feedback/client */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the ui-conversation SlotMap merge (the assistant-actions entry). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { MessageFeedbackController } from './controller.ts' import { MessageFeedbackActions } from './MessageFeedbackActions.tsx' import type { MessageFeedbackInjected } from './slots.ts' diff --git a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx index da51016468..bd894ebacf 100644 --- a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx @@ -11,7 +11,8 @@ import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import { cleanup } from '@testing-library/react' -import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types' diff --git a/packages/client/ui-message-feedback/tsconfig.json b/packages/client/ui-message-feedback/tsconfig.json index f46af0e619..36d5d1b762 100644 --- a/packages/client/ui-message-feedback/tsconfig.json +++ b/packages/client/ui-message-feedback/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../../core/session" + }, { "path": "../../typert/protocol" }, @@ -27,7 +30,13 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../ui-chat" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" }, { "path": "../ui-conversation" diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 646ddfef0b..04d454ccb3 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -31,9 +31,12 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-api-remotes" ], @@ -47,20 +50,24 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -68,9 +75,14 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index b3eefb1a34..dc4c4fb392 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -6,10 +6,12 @@ * either entry is what the other shows next. */ import type { - IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionId, SessionModels, -} from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionModels, +} from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { @@ -50,7 +52,7 @@ export class ModelDirectory { * @param available - whether this session may use Agent-bound model RPCs. */ constructor( - private readonly sessions: Pick, + private readonly sessions: Pick, private readonly sessionId: SessionId, private readonly available: () => boolean, ) {} @@ -64,7 +66,7 @@ export class ModelDirectory { this.assertAvailable() const generation = ++this.generation this.store.update((s) => { s.status = 'loading'; s.error = null }) - const { result } = await this.sessions.models({ sessionId: this.sessionId }) + const result = await this.sessions.models({ sessionId: this.sessionId }) if (this.disposed || generation !== this.generation) { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) return result.value @@ -95,7 +97,7 @@ export class ModelDirectory { this.assertAvailable() const generation = ++this.generation this.store.update((s) => { s.status = 'selecting'; s.error = null }) - const { result } = await this.sessions.selectModel({ + const result = await this.sessions.selectModel({ sessionId: this.sessionId, provider: selection.provider, model: selection.model, diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index fac380ef8e..a2359a87b3 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -12,13 +12,16 @@ * history outside the direct-parent continuation path. */ // Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. -import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-remotes/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-api-session-controller/types' +import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.model seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelDirectoryState } from './directory.ts' import { ModelDirectoryResolver } from './service.ts' @@ -97,7 +100,7 @@ function selectionOf(state: ModelDirectoryState, id: string): ModelSelection | u const NS = 'model' /** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */ -export const inject = ['commandUi', 'connection', 'locale', 'sessions', 'slots', 'remote'] +export const inject = ['commandUi', 'locale', 'sessions', 'slots', 'remote', 'remote.session'] /** * Client plugin body: mount ModelDirectoryResolver, register the `model` dictionaries, @@ -122,7 +125,7 @@ export function apply(ctx: ClientContext): void { ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => { const command = scope.get('commandUi') as CommandUiContract const models = scope.modelDirectories - const sessions = scope.sessions + const sessions = resolveClientSessions(scope) scope.effect(() => command.register({ name: 'model', description: t('command.description'), @@ -153,7 +156,7 @@ export function apply(ctx: ClientContext): void { // Entry 2: the composer's named model seat over the SAME directory. ctx.inject(['slots', 'modelDirectories'], (scope: ClientContext) => { const models = scope.modelDirectories - const sessions = scope.sessions + const sessions = resolveClientSessions(scope) scope.slots.inject('conversation.input.model', () => scope.slots.register({ name: 'conversation.input.model', locale: NS, diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index fe149ccfe3..ce981f6d14 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -14,8 +14,8 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionRuntime } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ModelDirectory } from './directory.ts' declare module '@deepseek-ai/cordis' { @@ -32,7 +32,7 @@ interface LiveState { /** The `ctx.modelDirectories` session model-selection service. */ export class ModelDirectoryResolver extends Service { - static inject = ['connection', 'sessions', 'remote'] + static inject = ['sessions', 'remote', 'remote.session'] private readonly live: LiveState = { directories: new Map() } @@ -70,12 +70,11 @@ export class ModelDirectoryResolver extends Service { const { live } = this const existing = live.directories.get(sessionId) if (existing !== undefined) return existing - const sessions = this.ctx.get('sessions') as SessionRuntime + const sessions = resolveClientSessions(this.ctx) const actx = sessions.scope(sessionId) if (actx === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`) - const connection = this.ctx.get('connection') as ConnectionHandle const directory = new ModelDirectory( - connection.api.sessions, + this.ctx.remote.session, sessionId, () => sessions.subagentAddress(sessionId) === undefined, ) diff --git a/packages/client/ui-model-selection/src/client/slots.ts b/packages/client/ui-model-selection/src/client/slots.ts index 91124a81f6..3924b5a95f 100644 --- a/packages/client/ui-model-selection/src/client/slots.ts +++ b/packages/client/ui-model-selection/src/client/slots.ts @@ -5,7 +5,7 @@ * merge lives here. */ import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ModelDirectoryState } from './directory.ts' /** Injected business face of the composer model seat. */ diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index aad5dda614..795d2487be 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -10,11 +10,11 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { createScope } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { createScope } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' +import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types' import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ModelSelectInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' @@ -58,12 +58,10 @@ async function bench() { const ctx = new Context() let current: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } const calls = { models: 0, select: 0 } - ctx.provide('connection', { api: { sessions: { + const sessionRemote = { models: () => { calls.models += 1 - return Promise.resolve({ - result: { ok: true as const, value: { current, routable, groups: GROUPS, failures: [] } }, - }) + return Promise.resolve({ ok: true as const, value: { current, routable, groups: GROUPS, failures: [] } }) }, selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => { calls.select += 1 @@ -74,9 +72,11 @@ async function bench() { ? {} : { reasoningEffort: payload.reasoningEffort }, } - return Promise.resolve({ result: { ok: true as const, value: { selected: current } } }) + return Promise.resolve({ ok: true as const, value: { selected: current } }) }, - } } }) + } + const remote = Object.assign(new TestRemote(ctx), { session: sessionRemote }) + ctx.reflect.provide('remote.session', sessionRemote) // Whether the Host reports an adapter for the current route; the composer // block follows this, never catalog membership. let routable = true @@ -118,7 +118,6 @@ async function bench() { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) - new TestRemote(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() await ctx.plugin(function probe() {}).await() @@ -128,7 +127,7 @@ async function bench() { return handle } return { - ctx, fiber, mint, calls, + ctx, fiber, mint, calls, remote, contribution: () => contribution!, seat: () => seats.get('conversation.input.model')!, hostCurrent: () => current, @@ -252,14 +251,14 @@ describe('ui-model-selection dual entry', () => { expect(b.blockOf('s1')).toBeUndefined() b.setRoutable(false) - b.ctx.remote.$dispatch('llm/adapters-updated', []) + b.remote.emit('llm/adapters-updated', []) await Promise.resolve() await Promise.resolve() expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) // Recovering clears it without a reload of the surface. b.setRoutable(true) - b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) + b.remote.emit('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() await Promise.resolve() expect(b.blockOf('s1')).toBeUndefined() diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index ea9c2f7abb..166eb6d74e 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' diff --git a/packages/client/ui-model-selection/tsconfig.json b/packages/client/ui-model-selection/tsconfig.json index f8c67f1764..7dbfc4369d 100644 --- a/packages/client/ui-model-selection/tsconfig.json +++ b/packages/client/ui-model-selection/tsconfig.json @@ -18,7 +18,7 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../ui-commands" @@ -29,6 +29,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-input-trigger" }, @@ -39,7 +45,10 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../api/remotes/tsconfig.client.json" + "path": "../../core/session" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" } ] } diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 55c6b4203a..d178d3dcef 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -31,10 +31,13 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-settings" @@ -50,21 +53,24 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^" + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -74,7 +80,9 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-permission-presets/src/client/PermissionRow.tsx b/packages/client/ui-permission-presets/src/client/PermissionRow.tsx index ae8c8bafe7..801c8f7cb9 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionRow.tsx +++ b/packages/client/ui-permission-presets/src/client/PermissionRow.tsx @@ -5,7 +5,7 @@ */ import { useEffect, useState } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14, Menu, RiskConfirmation, diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index 5248dc31d9..e7d346cfef 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -13,15 +13,18 @@ * The General-settings row separately writes the default preset for sessions * created later through the host Settings API. */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +import { resolveClientSessions, type SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the settings slot types (this package registers a General row). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (the settings invalidation rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client' @@ -80,7 +83,7 @@ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectO */ export function apply(ctx: ClientContext): void { const command = ctx.get('commandUi') as CommandUiContract - const sessions = ctx.sessions + const sessions = resolveClientSessions(ctx) // This optional bundle and ui-conversation can load independently, so each // owns the same safety copy under its own locale namespace. /* jscpd:ignore-start */ diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 89007c36e6..2f78d7c4b3 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -11,7 +11,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-store' import type { SchemaNode, SettingsDescribeFace, SettingsSchemaService, } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index 19ae476a37..ef41a39c00 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -10,7 +10,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -39,9 +40,7 @@ async function bench() { const locale = new LocaleRuntime(ctx) locale.setLocale('en') ctx.provide('locale', locale) - // The plugin injects `remote`; forwarded events reach it through the same - // `$dispatch` handoff the connection sink makes. - new TestRemote(ctx) + const remote = new TestRemote(ctx) ctx.slots.register({ name: 'root', children: { @@ -90,7 +89,7 @@ async function bench() { const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { - ctx, fiber, values, commands, + ctx, fiber, values, commands, remote, setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, decoration: () => decoration, permissionRow: () => ctx.slots.entries('settings.general.item') @@ -164,8 +163,8 @@ describe('ui-permission browser plugin', () => { it('disposal removes the decoration (HMR safety)', async () => { const b = await bench() expect(b.decoration()).toBeDefined() - b.ctx.remote.$dispatch('settings/document-updated', ['another', 1]) - b.ctx.remote.$dispatch('settings/document-updated', ['permission', 1]) + b.remote.emit('settings/document-updated', ['another', 1]) + b.remote.emit('settings/document-updated', ['permission', 1]) b.ctx.emit('connection/reset') await b.fiber.dispose() expect(b.decoration()).toBeUndefined() diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 84a9065792..5ab109883c 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -49,8 +49,12 @@ function ok(value: T) { const dictionary: Record = en const t: PermissionRowProps['t'] = key => dictionary[key] ?? key +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInteraction'] = selector => selector(noAttention) const runtime = { useSessions: (() => { throw new Error('unused') }) as never, + useSessionPendingInteraction, useWorkspaces: (() => { throw new Error('unused') }) as never, } diff --git a/packages/client/ui-permission-presets/tsconfig.json b/packages/client/ui-permission-presets/tsconfig.json index 1bb48eee02..bc10ea7c5a 100644 --- a/packages/client/ui-permission-presets/tsconfig.json +++ b/packages/client/ui-permission-presets/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../ui-commands" @@ -23,6 +23,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-input-trigger" }, @@ -38,6 +44,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../ui-settings" } diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index d9849f1101..8c3f87f136 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -47,16 +47,17 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -65,7 +66,10 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index a19a028bb6..42d080da76 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -8,13 +8,16 @@ * plan state. */ import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the `plan` SessionProjectionMap merge for useProjection. import type {} from '@deepseek-ai/dsh-plan-mode/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { PlanChip } from './PlanModeControl.tsx' import { en, zh, type PlanKey } from './locales.ts' diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index 347f38d552..9fe26757fa 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -7,8 +7,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { PlanChip } from '../src/client/PlanModeControl.tsx' import type { PlanChipInjected } from '../src/client/index.ts' diff --git a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx index 04ec113ef3..827361bbd0 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx @@ -7,7 +7,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client' import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.tsx' diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 7b234d08e7..6cfe159b48 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../runtime" - }, { "path": "../locale" }, @@ -26,6 +23,12 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 731b0fd8a0..340aa2bceb 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger" @@ -48,7 +47,6 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -59,7 +57,6 @@ "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 312db17222..2374748429 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -9,7 +9,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ClientSessionContext, InputTriggerServiceContract, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 3e813b9e11..5092af1931 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -6,7 +6,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { CandidateRequest, ClientSessionContext, InputTriggerCandidate, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index 4fdd19dbc2..cd661610fa 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../locale" }, - { - "path": "../runtime" - }, { "path": "../ui-input-trigger" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 4c21e3063b..88729acada 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -31,9 +31,6 @@ }, "dsh": { "client": { - "inject": [ - "@deepseek-ai/dsh-client-runtime" - ], "platform": "web", "immediately": true } @@ -47,13 +44,11 @@ "use-sync-external-store": "1.2.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-renderer/src/client/app.tsx b/packages/client/ui-renderer/src/client/app.tsx index 446d184c5b..df4399ccd5 100644 --- a/packages/client/ui-renderer/src/client/app.tsx +++ b/packages/client/ui-renderer/src/client/app.tsx @@ -4,13 +4,10 @@ */ import type { ReactNode } from 'react' import type { Context } from '@deepseek-ai/cordis' -import { bindSnapshotSelector } from './bind.ts' -import { DocumentTitle } from './DocumentTitle.tsx' -import type {} from '@deepseek-ai/dsh-client-runtime/client' /** Inputs available after the UI renderer's inject set activates. */ export interface AssemblyDeps { - /** Client context carrying the slots and sessions services. */ + /** Client context carrying the renderer-owned Slot registry. */ ctx: Context } @@ -21,20 +18,5 @@ export interface AssemblyDeps { */ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode { const { ctx } = deps - const sessions = ctx.get('sessions') - if (sessions === undefined) throw new Error('ui renderer: sessions service unavailable') - const useSessions = bindSnapshotSelector(sessions.list) - const SessionDocumentTitle = (): ReactNode => { - const title = useSessions((state) => { - const id = state.current - return id === undefined ? undefined : state.byId[id]?.title - }) - return - } - return () => ( - <> - - {ctx.slots.renderSlot('root', {})} - - ) + return () => ctx.slots.renderSlot('root', {}) } diff --git a/packages/client/ui-renderer/src/client/bindings.tsx b/packages/client/ui-renderer/src/client/bindings.tsx new file mode 100644 index 0000000000..fde55a366c --- /dev/null +++ b/packages/client/ui-renderer/src/client/bindings.tsx @@ -0,0 +1,143 @@ +/** Internal React bindings for renderer hosts and standard-source scopes. */ +import { createContext, useContext, type ReactNode } from 'react' +import type { + HostObservable, + KeyedStandardSource, + MaybeSnapshotSelectorHook, + SlotRendererHost, + SnapshotSelectorHook, + StandardSourceBinding, +} from '@deepseek-ai/dsh-client-ui-slots' +import { bindSnapshotSelector } from './bind.ts' + +/** Missing renderer assembly dependency. */ +export class SlotAssemblyError extends Error {} + +/** In-package renderer host context. */ +export const HostContext = createContext(null) + +/** + * Read the installed renderer host. + * @returns the host API. + */ +export function useHost(): SlotRendererHost { + const host = useContext(HostContext) + if (host === null) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree') + return host +} + +const RootBindingContext = createContext(null) +const ScopeBindingContext = createContext(null) + +/** + * Read the root standard-source binding. + * @returns the current root binding. + */ +export function useRootBinding(): StandardSourceBinding { + const binding = useContext(RootBindingContext) + if (binding === null) throw new SlotAssemblyError('slot rendered outside the root standard-source provider') + return binding +} + +/** + * Read the current-session-optional binding. + * @returns a binding whose key is absent when no Session is selected. + */ +export function useScopeBinding(): StandardSourceBinding { + const binding = useContext(ScopeBindingContext) + if (binding === null) throw new SlotAssemblyError('scoped slot rendered outside its scope provider') + return binding +} + +/** + * Bind one observable source to an identity-stable selector Hook. + * @param source - observable source. + * @returns cached selector Hook. + */ +export function observableHook(source: HostObservable): SnapshotSelectorHook { + let hook = hookCache.get(source) + if (hook === undefined) { + hook = bindSnapshotSelector(source) + hookCache.set(source, hook) + } + return hook as SnapshotSelectorHook +} + +const hookCache = new WeakMap() +const absentSource: HostObservable = { + getSnapshot: () => undefined, + subscribe: () => () => {}, +} + +/** + * Bind an optional source without changing Hook call order. + * @param source - current source, or absence. + * @returns selector Hook returning `undefined` while absent. + */ +export function maybeObservableHook( + source: HostObservable | undefined, +): MaybeSnapshotSelectorHook { + if (source !== undefined) return observableHook(source) + return useAbsentSnapshot +} + +function useAbsentSnapshot( + _selector: (snapshot: never) => S, + _equal?: (left: S, right: S) => boolean, +): S | undefined { + observableHook(absentSource)(() => undefined) + return undefined +} + +/** Erased open-key selector Hook synthesized from one keyed source family. */ +export type KeyedSnapshotHook = ( + key: string, + selector?: (value: unknown) => unknown, + equal?: (left: unknown, right: unknown) => boolean, +) => unknown + +/** + * Bind an open-key source family. + * @param source - keyed resolver, or absence for an optional scope. + * @returns cached keyed selector Hook. + */ +export function keyedObservableHook(source: KeyedStandardSource | undefined): KeyedSnapshotHook { + if (source === undefined) return absentKeyedHook + let hook = keyedHookCache.get(source) + if (hook === undefined) { + hook = (key, selector, equal) => { + const useValue = observableHook(source(key) ?? absentSource) + return useValue(selector ?? identity, equal) + } + keyedHookCache.set(source, hook) + } + return hook +} + +const keyedHookCache = new WeakMap() +const identity = (value: unknown): unknown => value +const absentKeyedHook: KeyedSnapshotHook = (_key, selector, equal) => + observableHook(absentSource)(selector ?? identity, equal) + +/** Subscribe the tree to the atomically assembled root standard-source roster. */ +export function RootStandardProvider({ children }: { children: ReactNode }) { + const host = useHost() + const binding = observableHook(host.root)(value => value) + return {children} +} + +/** Subscribe to the scope roster before resolving and binding its current adapter. */ +export function ScopeProvider({ + scope, + children, +}: { + scope: 'session' | 'session-maybe' + children: ReactNode +}) { + const host = useHost() + observableHook(host.scopeRevision)(value => value) + const adapter = host.scope(scope) + if (adapter === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without an installed adapter`) + const binding = observableHook(adapter.current)(value => value) + return {children} +} diff --git a/packages/client/ui-renderer/src/client/index.ts b/packages/client/ui-renderer/src/client/index.ts index a7f45bd354..b559194653 100644 --- a/packages/client/ui-renderer/src/client/index.ts +++ b/packages/client/ui-renderer/src/client/index.ts @@ -7,18 +7,18 @@ import { createElement, useLayoutEffect, useState, type ReactNode } from 'react' import { flushSync } from 'react-dom' import { createRoot, hydrateRoot, type Root } from 'react-dom/client' import type { Context } from '@deepseek-ai/cordis' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer } from './scoped-slots.tsx' import { buildRenderApp } from './app.tsx' +import { SlotRegistry } from './registry.ts' -/** Selector hook over a session's conversation snapshot. */ -export type UseSession = SnapshotSelectorHook +export { SlotRegistry } from './registry.ts' +export type { RootOwnerProps } from './registry.ts' export type { - ChainRenderOpts, HostObservable, RenderOpts, SessionProvideInfo, SnapshotSelectorHook, - SlotRenderer, SlotRendererHost, StoreInstanceLike, + ChainRenderOpts, HostObservable, RenderOpts, SnapshotSelectorHook, SlotRenderer, + ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, + StandardSourceBinding, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' -export type { SessionProviderProps } from './session-provider.tsx' /** Mount operation exposed to the framework-free boot kernel. */ export interface UiRendererService { @@ -31,14 +31,24 @@ export interface UiRendererService { } declare module '@deepseek-ai/cordis' { + interface Events { + /** + * A slot declaration or registration set changed. + * @mode emit + * @param key - mutated SlotMap key. + */ + 'slots/changed'(key: string): void + } interface Context { + /** Renderer-owned UI composition registry. */ + slots: SlotRegistry /** Mount face provided after the UI renderer activates. */ uiRenderer: UiRendererService } } /** Services required before application assembly. */ -export const inject = ['slots', 'sessions'] +export const inject: string[] = [] interface BootSnapshot { className: string @@ -76,7 +86,8 @@ function mountApp(container: HTMLElement, app: () => ReactNode): Root { * @param ctx - Plugin context. */ export function apply(ctx: Context): void { - ctx.slots.install(createSlotRenderer()) + const slots = new SlotRegistry(ctx) + slots.install(createSlotRenderer()) ctx.reflect.provide('uiRenderer', { mount: (container: HTMLElement): (() => void) => { const root = mountApp(container, buildRenderApp({ ctx })) diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/ui-renderer/src/client/registry.ts similarity index 72% rename from packages/client/runtime/src/client/slots.ts rename to packages/client/ui-renderer/src/client/registry.ts index 9cd76b8f8e..728cbdcb21 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/ui-renderer/src/client/registry.ts @@ -1,8 +1,8 @@ /** - * SlotRegistry: the cordis Service layer of the slot system over the pure + * SlotRegistry: the renderer-owned Cordis service over the pure * SlotCore (ui-slots owns registration semantics, the declaration ledger, * the load-time validations, and the unload cascade). This layer owns what - * needs the runtime: the 'slots/changed' event bridge, register and + * needs a live application: the 'slots/changed' event bridge, register and * declaration injection through the caller's ctx.effect (fiber unload * collects both), the renderer installation contract (install()/renderSlot('root') + * the SlotRendererHost face), and the store INSTANCE axis — handle x scope @@ -16,10 +16,12 @@ * redundancy. */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotCore, standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots' import type { - LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, - SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike, + HostObservable, LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, + RootStandardSourceContribution, ScopedStandardSourceBinding, SlotScope, SlotScopeAdapter, SlotSpec, + StandardSourceBinding, + StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -94,9 +96,36 @@ export class SlotRegistry extends Service { private readonly _core = new SlotCore() /** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */ private readonly _stores = new Map() + /** Latest live Context generation for each scoped store key. */ + private readonly _storeScopeOwners = new Map() private _renderer: SlotRenderer | undefined private _locale: LocaleFace | undefined private _host: SlotRendererHost | undefined + private readonly _rootContributions: RootStandardSourceContribution[] = [] + private readonly _rootListeners = new Set<() => void>() + private _rootBinding: StandardSourceBinding = { + key: undefined, + hooks: {}, + keyedHooks: {}, + props: {}, + } + private readonly _rootSource = { + getSnapshot: (): StandardSourceBinding => this._rootBinding, + subscribe: (listener: () => void): (() => void) => { + this._rootListeners.add(listener) + return () => { this._rootListeners.delete(listener) } + }, + } + private readonly _scopes = new Map, SlotScopeAdapter>() + private _scopeRevision = 0 + private readonly _scopeListeners = new Set<() => void>() + private readonly _scopeRevisionSource: HostObservable = { + getSnapshot: () => this._scopeRevision, + subscribe: (listener) => { + this._scopeListeners.add(listener) + return () => { this._scopeListeners.delete(listener) } + }, + } /** * @param ctx - owning root context. @@ -237,6 +266,74 @@ export class SlotRegistry extends Service { }, 'slots.installLocale()') } + /** + * Contribute domain-owned root data. Hook names must be globally unique; + * registration and disposal republish one atomic root binding. + * @param contribution - bare sources and stable props. + * @returns disposer owned by the caller's Cordis fiber. + */ + provideRoot(contribution: RootStandardSourceContribution): () => void { + const dispose = this.ctx.effect(() => { + this._rootContributions.push(contribution) + try { + this.rebuildRootBinding() + } catch (error) { + this._rootContributions.pop() + throw error + } + return () => { + const index = this._rootContributions.indexOf(contribution) + if (index === -1) return + this._rootContributions.splice(index, 1) + this.rebuildRootBinding() + } + }, 'slots.provideRoot()') + return () => { void dispose() } + } + + /** + * Install the owner adapter for one strict scope. Its optional counterpart + * resolves through the same adapter. + * @param scope - strict scope name. + * @param adapter - current/resolved binding source and release notifications. + */ + installScope( + scope: Exclude, + adapter: SlotScopeAdapter, + ): void { + if (this._scopes.has(scope)) throw new Error(`slot scope '${scope}' already has an adapter`) + this.ctx.effect(() => { + this._scopes.set(scope, adapter) + this.publishScopeRevision() + return () => { + if (this._scopes.get(scope) === adapter) { + this._scopes.delete(scope) + this.publishScopeRevision() + } + } + }, `slots.installScope(${JSON.stringify(scope)})`) + } + + /** + * Bind all scoped Store handles to one owner Context lifetime. The cleanup + * materializes an otherwise-unused handle before clearing it, because a + * previous application run may have persisted state for a Slot that this + * scope never rendered. Rebinding the same key transfers cleanup ownership + * to the newest Context generation. + * + * @param binding - materialized scope identity and its owning Context. + */ + bindStoreScope(binding: Pick): void { + const current = this._storeScopeOwners.get(binding.key) + if (current === binding.ctx) return + this._storeScopeOwners.set(binding.key, binding.ctx) + binding.ctx.effect(() => () => { + if (this._storeScopeOwners.get(binding.key) !== binding.ctx) return + this._storeScopeOwners.delete(binding.key) + this.clearStoreScope(binding.key) + }, `slots: store scope ${binding.key}`) + } + /** * The single ctx-level render entry: the shell renders 'root'; every other * key renders inside components through the props renderSlot face. All @@ -261,23 +358,6 @@ export class SlotRegistry extends Service { return this._renderer.renderRoot(this.hostFace(), owner) } - /** - * Drop the per-session store instances of a dead session (the sessions - * service calls this on scope teardown; root-scoped records are untouched). - * Persisted state goes with the session — a never-rendered dead session can - * still own keys from an earlier page load, so the instance is materialized - * transiently just to clear storage (no-op for unpersisted stores). - * @param sessionId - the torn-down session. - */ - pruneStoreScope(sessionId: string): void { - for (const [handle, record] of this._stores) { - if (record.scope !== 'session') continue - const instance = record.instances.get(sessionId) ?? handle.create(sessionId) - instance.clearPersisted() - record.instances.delete(sessionId) - } - } - /** * Snapshot entries for a key (render-erased view; stable reference between mutations). * @param key - SlotMap key. @@ -381,17 +461,9 @@ export class SlotRegistry extends Service { } } - /** Build once after both object-layer services mount; per-session provide bundles still resolve lazily. */ + /** Build the domain-neutral host face once; installed adapters remain live through getters. */ private hostFace(): SlotRendererHost { if (this._host !== undefined) return this._host - const sessions = this.ctx.get('sessions') - if (sessions === undefined) { - throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first") - } - const workspaces = this.ctx.get('workspaces') - if (workspaces === undefined) { - throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") - } // `locale` is a live getter: the face installs (and, under HMR, swaps) // on the locale plugin's own fiber lifetime, while this host object is // built once — a captured value would strand renders on a dead face. The @@ -406,24 +478,66 @@ export class SlotRegistry extends Service { reportEntryError: (key, entry, error, info) => { this._core.reportEntryError(key, entry, error, info) }, specOf: key => this._core.specDynamic(key), isLive: entry => this._core.isLive(entry), - storeOf: (entry, scopeKey) => - entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), - sessions: { - list: sessions.list, - provideInfo: sessions.currentProvideInfo, - }, - workspaces: { list: workspaces.list }, + storeOf: (entry, scopeBinding) => + entry.store === undefined + ? undefined + : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeBinding), + root: this._rootSource, + scopeRevision: this._scopeRevisionSource, + scope: scope => service._scopes.get(scope === 'session-maybe' ? 'session' : scope), get locale() { return service._locale }, } return this._host } + /** Validate and atomically publish the current root contribution roster. */ + private rebuildRootBinding(): void { + const hooks: Record> = {} + const keyedHooks: Record = {} + const props: Record = {} + const finalProps = new Set() + for (const contribution of this._rootContributions) { + copyUnique('hook', hooks, contribution.hooks, finalProps, standardHookPropName) + copyUnique('keyed hook', keyedHooks, contribution.keyedHooks, finalProps, standardHookPropName) + copyUnique('prop', props, contribution.props, finalProps, name => name) + } + this._rootBinding = { key: undefined, hooks, keyedHooks, props } + for (const listener of [...this._rootListeners]) { + try { + listener() + } catch (error) { + console.error('root standard-source subscriber failed:', error) + } + } + } + + /** Publish one installed-scope roster transition after the map is authoritative. */ + private publishScopeRevision(): void { + this._scopeRevision += 1 + for (const listener of [...this._scopeListeners]) { + try { + listener() + } catch (error) { + console.error('scope-adapter subscriber failed:', error) + } + } + } + /** Resolve (create or reuse) the store instance for a registered handle under a scope key. */ - private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike { + private resolveStore( + handle: EngineStoreHandle, + scopeBinding: ScopedStandardSourceBinding | undefined, + ): StoreInstanceLike { const record = this._stores.get(handle) if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)') - const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : sessionId - if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`) + let key: string + if (record.scope === 'root') { + key = ROOT_INSTANCE_KEY + } else { + if (scopeBinding === undefined) throw new Error(`${record.scope} store resolution requires a session id`) + key = scopeBinding.key + this.bindStoreScope(scopeBinding) + } let instance = record.instances.get(key) if (instance === undefined) { // Session instances get the scope key (the engine suffixes the persist @@ -434,6 +548,16 @@ export class SlotRegistry extends Service { return instance } + /** Clear every live non-root Store handle for one dead scope key. */ + private clearStoreScope(key: string): void { + for (const [handle, record] of this._stores) { + if (record.scope === 'root') continue + const instance = record.instances.get(key) ?? handle.create(key) + instance.clearPersisted() + record.instances.delete(key) + } + } + /** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */ private _acquire(handle: EngineStoreHandle, scope: SlotScope): void { const record = this._stores.get(handle) @@ -452,7 +576,26 @@ export class SlotRegistry extends Service { * future call site cannot underflow the axis. */ if (record === undefined) return record.refs -= 1 - if (record.refs === 0) this._stores.delete(handle) + if (record.refs !== 0) return + this._stores.delete(handle) + } +} + +function copyUnique( + kind: string, + target: Record, + values: Readonly> | undefined, + finalProps: Set, + propNameOf: (name: string) => string, +): void { + if (values === undefined) return + for (const [name, value] of Object.entries(values)) { + const propName = propNameOf(name) + if (finalProps.has(propName)) { + throw new Error(`duplicate root standard ${kind} '${name}' at prop '${propName}'`) + } + finalProps.add(propName) + target[name] = value } } diff --git a/packages/client/ui-renderer/src/client/scoped-slots.tsx b/packages/client/ui-renderer/src/client/scoped-slots.tsx index dea0511319..05ae2684d7 100644 --- a/packages/client/ui-renderer/src/client/scoped-slots.tsx +++ b/packages/client/ui-renderer/src/client/scoped-slots.tsx @@ -4,15 +4,17 @@ */ import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react' import { - SlotOwnershipError, StaleAuthorizationError, + SlotOwnershipError, StaleAuthorizationError, standardHookPropName, type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts, - type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, - type SlotScope, type StoredEntry, type Translate, + type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer, + type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type StandardSourceBinding, + type StoredEntry, type Translate, } from '@deepseek-ai/dsh-client-ui-slots' import { - HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook, - observableHook, projectionHook, useHost, useSessionMaybeProvideInfo, -} from './session-provider.tsx' + HostContext, RootStandardProvider, ScopeProvider, SlotAssemblyError, + keyedObservableHook, maybeObservableHook, observableHook, useHost, useRootBinding, + useScopeBinding, +} from './bindings.tsx' type InjectedProps = Record @@ -89,23 +91,23 @@ function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): Rende /** * Inject results cache: root entries per entry, session entries per - * (entry x provide bundle). WeakMap keys are entry/info objects (both + * (entry x scope binding). WeakMap keys are entry/binding objects (both * identity-stable per registration/session scope), so cache lifetime rides * the same axes as the values it memoizes. */ const rootInjectCache = new WeakMap() -const sessionInjectCache = new WeakMap>() -const sessionMaybeInjectCache = new WeakMap>() +const sessionInjectCache = new WeakMap>() +const sessionMaybeInjectCache = new WeakMap>() const EMPTY_INJECTED_PROPS: InjectedProps = {} -function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps { +function runInject(entry: StoredEntry, binding: StandardSourceBinding | undefined, actions: object | undefined): InjectedProps { const inject = entry.inject if (!inject) return EMPTY_INJECTED_PROPS // Declaration-derived positional arguments: sessionId for session scope, // baked actions when a store is declared. const args: unknown[] = [] - if (info !== undefined) args.push(info.sessionId) + if (binding !== undefined) args.push(binding.key) if (actions !== undefined) args.push(actions) return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args)) } @@ -120,7 +122,7 @@ function bindInjectHooks(face: InjectedProps): InjectedProps { const { hooks: _hooks, ...rest } = face const bound: InjectedProps = rest for (const [name, source] of Object.entries(sources as Record>)) { - const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + const hookName = standardHookPropName(name) bound[hookName] = observableHook(source) } return bound @@ -144,7 +146,7 @@ function cachedSlotInject(face: object | undefined): BoundSlotInject { const props: InjectedProps = rest let factories: Record | undefined for (const [name, definition] of Object.entries(definitions as Record)) { - const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + const hookName = standardHookPropName(name) if (typeof definition === 'function') { factories ??= {} factories[name] = definition as SlotHookFactory @@ -167,7 +169,7 @@ function bindSlotHookFactories( ): InjectedProps { const hooks: InjectedProps = {} for (const [name, factory] of Object.entries(factories)) { - const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + const hookName = standardHookPropName(name) hooks[hookName] = factory(standard, hookContext) } return hooks @@ -182,34 +184,34 @@ function cachedRootInject(entry: StoredEntry, actions: object | undefined): Inje return props } -function cachedSessionInject(entry: StoredEntry, info: SessionProvideInfo, actions: object | undefined): InjectedProps { - let perInfo = sessionInjectCache.get(entry) - if (!perInfo) { - perInfo = new WeakMap() - sessionInjectCache.set(entry, perInfo) +function cachedSessionInject(entry: StoredEntry, binding: StandardSourceBinding, actions: object | undefined): InjectedProps { + let perBinding = sessionInjectCache.get(entry) + if (!perBinding) { + perBinding = new WeakMap() + sessionInjectCache.set(entry, perBinding) } - let props = perInfo.get(info) + let props = perBinding.get(binding) if (!props) { - props = runInject(entry, info, actions) - perInfo.set(info, props) + props = runInject(entry, binding, actions) + perBinding.set(binding, props) } return props } function cachedSessionMaybeInject( entry: StoredEntry, - info: SessionMaybeProvideInfo, + binding: StandardSourceBinding, actions: object | undefined, ): InjectedProps { - let perInfo = sessionMaybeInjectCache.get(entry) - if (!perInfo) { - perInfo = new WeakMap() - sessionMaybeInjectCache.set(entry, perInfo) + let perBinding = sessionMaybeInjectCache.get(entry) + if (!perBinding) { + perBinding = new WeakMap() + sessionMaybeInjectCache.set(entry, perBinding) } - let props = perInfo.get(info) + let props = perBinding.get(binding) if (!props) { - props = runInject(entry, info, actions) - perInfo.set(info, props) + props = runInject(entry, binding, actions) + perBinding.set(binding, props) } return props } @@ -332,54 +334,76 @@ class SlotErrorBoundary extends Component< } } -interface StandardPropsCache { - readonly root: InjectedProps - readonly session: WeakMap - readonly sessionMaybe: WeakMap -} +const rootStandardCache = new WeakMap() +const sessionStandardCache = new WeakMap>() +const sessionMaybeStandardCache = new WeakMap>() -const standardPropsCache = new WeakMap() +/** Materialize one binding into stable framework Hook and plain-prop seats. */ +function materializeStandardBinding(binding: StandardSourceBinding, optional: boolean): InjectedProps { + const standard: InjectedProps = { ...binding.props } + for (const [name, source] of Object.entries(binding.hooks)) { + if (source === undefined && !optional) { + throw new SlotAssemblyError(`strict standard hook '${name}' has no source`) + } + standard[standardHookPropName(name)] = optional + ? maybeObservableHook(source) + : observableHook(source as HostObservable) + } + for (const [name, source] of Object.entries(binding.keyedHooks)) { + if (source === undefined && !optional) { + throw new SlotAssemblyError(`strict keyed standard hook '${name}' has no source resolver`) + } + standard[standardHookPropName(name)] = keyedObservableHook(source) + } + return standard +} /** Stable official-props object used by contextual Hook factories. */ function standardProps( - host: SlotRendererHost, scope: SlotScope, - info: SessionMaybeProvideInfo | undefined, + rootBinding: StandardSourceBinding, + scopeBinding: StandardSourceBinding | undefined, ): InjectedProps { - let cache = standardPropsCache.get(host) - if (cache === undefined) { - cache = { - root: { - useSessions: observableHook(host.sessions.list), - useWorkspaces: observableHook(host.workspaces.list), - }, - session: new WeakMap(), - sessionMaybe: new WeakMap(), - } - standardPropsCache.set(host, cache) + let root = rootStandardCache.get(rootBinding) + if (root === undefined) { + root = materializeStandardBinding(rootBinding, false) + rootStandardCache.set(rootBinding, root) } - if (scope === 'root') return cache.root - if (info === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without session provide info`) - const byInfo = scope === 'session' ? cache.session : cache.sessionMaybe - let standard = byInfo.get(info) + if (scope === 'root') return root + if (scopeBinding === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without a standard-source binding`) + const cache = scope === 'session' ? sessionStandardCache : sessionMaybeStandardCache + let perScope = cache.get(rootBinding) + if (perScope === undefined) { + perScope = new WeakMap() + cache.set(rootBinding, perScope) + } + let standard = perScope.get(scopeBinding) if (standard !== undefined) return standard - standard = { ...cache.root } - for (const [name, source] of Object.entries(info.hooks)) { - const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` - if (scope === 'session-maybe') { - standard[hookName] = maybeObservableHook(source) - } else { - if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`) - standard[hookName] = observableHook(source) - } + standard = { + ...root, + ...materializeStandardBinding(scopeBinding, scope === 'session-maybe'), } - Object.assign(standard, info.props) - standard['sessionId'] = info.sessionId - standard['useProjection'] = projectionHook(info) - byInfo.set(info, standard) + perScope.set(scopeBinding, standard) return standard } +const scopeAreaCache = new WeakMap() + +/** Bind one domain-owned scope area renderer to the current scope binding. */ +function scopeAreaProvider(adapter: SlotScopeAdapter): SessionProviderComponent { + let Provider = scopeAreaCache.get(adapter) + if (Provider !== undefined) return Provider + if (adapter.renderArea === undefined) { + throw new SlotAssemblyError("scope 'session' adapter does not provide its area renderer") + } + const renderArea = adapter.renderArea.bind(adapter) + Provider = function ScopeAreaProvider(props: SessionAreaProps): ReactNode { + return renderArea(useScopeBinding(), props) + } + scopeAreaCache.set(adapter, Provider) + return Provider +} + /** * Standard-kit synthesis shared by both scope branches: the global * useSessions/useWorkspaces hooks, the per-session provide bundle (every @@ -396,13 +420,14 @@ function standardKit( host: SlotRendererHost, entry: StoredEntry, scope: SlotScope, - info: SessionMaybeProvideInfo | undefined, + rootBinding: StandardSourceBinding, + scopeBinding: StandardSourceBinding | undefined, ): { kit: InjectedProps standard: InjectedProps actions: object | undefined } { - const standard = standardProps(host, scope, info) + const standard = standardProps(scope, rootBinding, scopeBinding) const kit: InjectedProps = { ...standard } if (entry.locale !== undefined) { const face = host.locale @@ -414,9 +439,10 @@ function standardKit( } kit['t'] = localeSeat(face, entry.locale) } - const store = scope === 'session-maybe' && info?.sessionId === undefined + const scopedStoreBinding = scopeBinding?.key === undefined ? undefined - : host.storeOf(entry, info?.sessionId) + : scopeBinding as ScopedStandardSourceBinding + const store = host.storeOf(entry, scopedStoreBinding) if (store !== undefined) { // The instance IS an observable snapshot source (contract getSnapshot/ // subscribe); the useStore hook binds here, cached per instance. @@ -430,11 +456,14 @@ function standardKit( if (Object.values(entry.children).some(spec => spec.kind === 'chain')) { kit['renderSlotChain'] = boundRenderSlotChain(host, entry) } - // SessionProvider standard seat: entries declaring a session-scope child - // render the session area, so the framework hands them the self-wired - // provider (module-level component = stable reference; no value import). + // The session owner supplies area semantics; the renderer only binds its + // adapter to the current generic scope source. if (Object.values(entry.children).some(spec => spec.scope === 'session')) { - kit['SessionProvider'] = SessionProvider + const adapter = host.scope('session') + if (adapter === undefined) { + throw new SlotAssemblyError("entry declares a session child without an installed 'session' scope adapter") + } + kit['SessionProvider'] = scopeAreaProvider(adapter) } } return { kit, standard, actions: store?.actions } @@ -499,35 +528,37 @@ function renderEntry( ) } -function SessionEntry({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: { +function SessionEntry({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: { entry: StoredEntry ownerProps: object - info: SessionProvideInfo + binding: StandardSourceBinding & { readonly key: string } slotKey: string slotInjected: BoundSlotInject hookContext: unknown hasHookContext: boolean }) { const host = useHost() + const rootBinding = useRootBinding() const Comp = entry.component as FC - const { kit, standard, actions } = standardKit(host, entry, 'session', info) - const injected = cachedSessionInject(entry, info, actions) + const { kit, standard, actions } = standardKit(host, entry, 'session', rootBinding, binding) + const injected = cachedSessionInject(entry, binding, actions) return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } -function SessionMaybeEntryBody({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: { +function SessionMaybeEntryBody({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: { entry: StoredEntry ownerProps: object - info: SessionMaybeProvideInfo + binding: StandardSourceBinding slotKey: string slotInjected: BoundSlotInject hookContext: unknown hasHookContext: boolean }) { const host = useHost() + const rootBinding = useRootBinding() const Comp = entry.component as FC - const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', info) - const injected = cachedSessionMaybeInject(entry, info, actions) + const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', rootBinding, binding) + const injected = cachedSessionMaybeInject(entry, binding, actions) return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } @@ -552,7 +583,7 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte hookContext: unknown hasHookContext: boolean }) { - const info = useSessionMaybeProvideInfo() + const binding = useScopeBinding() // The child key is an incarnation counter, NOT the session id: adoption // must keep the key constant across undefined → first id. Bookkeeping // lives in this stable (unkeyed) wrapper via the render-phase setState @@ -561,16 +592,16 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte // guard conditions make it convergent — StrictMode-safe). const [state, setState] = useState(FIRST_INCARNATION) let { adopted, epoch } = state - if (info.sessionId !== undefined && adopted === undefined) { + if (binding.key !== undefined && adopted === undefined) { // Adoption: same epoch — no remount. - adopted = info.sessionId + adopted = binding.key setState({ adopted, epoch }) - } else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) { + } else if (adopted !== undefined && binding.key !== undefined && binding.key !== adopted) { // Post-adoption session switch: next incarnation, born already adopted. - adopted = info.sessionId + adopted = binding.key epoch += 1 setState({ adopted, epoch }) - } else if (adopted !== undefined && info.sessionId === undefined) { + } else if (adopted !== undefined && binding.key === undefined) { // Back to no-session: next incarnation, born blank (adopts anew later). adopted = undefined epoch += 1 @@ -581,7 +612,7 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte key={epoch} entry={entry} ownerProps={ownerProps} - info={info} + binding={binding} slotKey={slotKey} slotInjected={slotInjected} hookContext={hookContext} @@ -609,8 +640,9 @@ function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasH hasHookContext: boolean }) { const host = useHost() + const rootBinding = useRootBinding() const Comp = entry.component as FC - const { kit, standard, actions } = standardKit(host, entry, 'root', undefined) + const { kit, standard, actions } = standardKit(host, entry, 'root', rootBinding, undefined) const injected = cachedRootInject(entry, actions) return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } @@ -624,16 +656,18 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont hasHookContext: boolean onEntryError: (error: unknown) => void }) { - const info = useSessionMaybeProvideInfo() - if (info.sessionId === undefined) return null + const binding = useScopeBinding() + if (binding.key === undefined) { + throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`) + } // Per-session remount rides this key; per-entry remount rides the outer // element's entry-identity key (the outlet's guarded() call). return ( - + - {renderOutletContent(host, slotKey, ownerProps, opts, sessionInfo)} + {renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding)}
) } @@ -685,20 +719,20 @@ function renderOutletContent( slotKey: string, ownerProps: object, opts: (RenderOpts & ChainRenderOpts) | undefined, - sessionInfo: SessionMaybeProvideInfo, + scopeBinding: StandardSourceBinding, ): ReactNode { const spec = host.specOf(slotKey) // Undeclared (or no-longer-declared) keys render empty: a declaring entry's // unload returns the slot to the undeclared state while retained elements // may still be mounted — natural empty, not an ownership failure. if (!spec) return null - const strictSessionAbsent = spec.scope === 'session' && sessionInfo.sessionId === undefined - if (strictSessionAbsent && (spec.kind !== 'chain' || !opts?.overlay)) { - return <>{opts?.fallback ?? null} + if (spec.kind === 'chain' && opts?.fallbackOnly === true) { + return renderChainResult(slotKey, null, opts) } - // An absent strict overlay chain follows its ordinary empty-election path, - // preserving the Fragment/fallback-wrapper shape across session arrival. - const entries = strictSessionAbsent ? [] : host.entriesOf(slotKey) + if (spec.scope === 'session' && scopeBinding.key === undefined) { + throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`) + } + const entries = host.entriesOf(slotKey) const slotInjected = cachedSlotInject(spec.inject) // The boundary must wrap the Entry ELEMENT, not live inside it: inject @@ -799,25 +833,7 @@ function renderOutletContent( break } } - if (opts?.overlay) { - // Overlay chain (ChainRenderOpts.overlay): the fallback stays mounted - // through elections — hidden via inline display:none (decisive over any - // author CSS), shown via display:contents so the wrapper never affects - // the owner's layout. The wrapper's tree position is constant, so React - // reconciles instead of remounting and fallback state survives takeover. - return ( - <> -
- {opts.fallback ?? null} -
- {elected} - - ) - } - return elected ?? <>{opts?.fallback ?? null} + return renderChainResult(slotKey, elected, opts) } // list: one row per id cell — the cell's shadowing winner, or the crash // face once every entry of the cell abdicated (a dry cell must not @@ -850,6 +866,26 @@ function renderOutletContent( ) } +/** Render a chain election while preserving the overlay fallback's tree position. */ +function renderChainResult( + slotKey: string, + elected: ReactNode, + opts: (RenderOpts & ChainRenderOpts) | undefined, +): ReactNode { + if (!opts?.overlay) return elected ?? <>{opts?.fallback ?? null} + return ( + <> +
+ {opts.fallback ?? null} +
+ {elected} + + ) +} + /** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank. */ function RootOutlet({ ownerProps }: { ownerProps: object }) { const host = useHost() @@ -889,7 +925,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) { } /** - * Build the renderer the shell installs into the runtime SlotRegistry + * Build the renderer installed into the `ui-renderer` SlotRegistry * (ctx.slots.install(createSlotRenderer()) at boot; the service owns the * install/renderSlot contract and the double-install/not-installed throws). * @returns the renderer. @@ -899,9 +935,11 @@ export function createSlotRenderer(): SlotRenderer { renderRoot(host, ownerProps) { return ( - - - + + + + + ) }, diff --git a/packages/client/ui-renderer/src/client/session-provider.tsx b/packages/client/ui-renderer/src/client/session-provider.tsx deleted file mode 100644 index 196a4d9fd9..0000000000 --- a/packages/client/ui-renderer/src/client/session-provider.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/** Internal React bindings for the renderer host and active session provide bundle. */ -import { createContext, useContext, type ReactNode } from 'react' -import type { - HostObservable, MaybeSnapshotSelectorHook, SessionMaybeProvideInfo, SessionProvideInfo, - SlotRendererHost, SnapshotSelectorHook, -} from '@deepseek-ai/dsh-client-ui-slots' -import { bindSnapshotSelector } from './bind.ts' - -/** - * A missing-provider assembly error: the shell wired the tree wrong. The slot - * error boundary rethrows this class so misassembly stays fail-loud while - * registrant errors (inject factories, entry components) are contained - * per entry. - */ -export class SlotAssemblyError extends Error {} - -/** In-package renderer host context. */ -export const HostContext = createContext(null) - -/** - * Read the installed renderer host; throws outside the rendered root tree - * (framework components must not render detached from the renderer). - * @returns the host API. - */ -export function useHost(): SlotRendererHost { - const host = useContext(HostContext) - if (!host) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree') - return host -} - -const BindingContext = createContext(null) - -/** Read the current-session-optional bundle supplied at the root. */ -export function useSessionMaybeProvideInfo(): SessionMaybeProvideInfo { - const info = useContext(BindingContext) - if (!info) throw new SlotAssemblyError('session-aware slot rendered outside the root binding provider') - return info -} - -/** - * Read the enclosing session provide bundle; throws outside a SessionProvider - * subtree (session slots must not render without a session). - * @returns the enclosing bundle. - */ -export function useSessionProvideInfo(): SessionProvideInfo { - const info = useSessionMaybeProvideInfo() - if (info.sessionId === undefined) throw new SlotAssemblyError('strict session slot rendered without a session') - return info as SessionProvideInfo -} - -/** - * Identity-stable selector hook per host observable. uSES resubscribes when - * the subscribe reference changes, so the bound hook must be created once per - * source — cached here by source identity (sources are host-owned singletons). - * @param source - host-provided observable. - * @returns the cached selector hook. - */ -export function observableHook(source: HostObservable): SnapshotSelectorHook { - let hook = hookCache.get(source) - if (hook === undefined) { - hook = bindSnapshotSelector(source) - hookCache.set(source, hook) - } - return hook as SnapshotSelectorHook -} -const hookCache = new WeakMap() - -const absentSource: HostObservable = { - getSnapshot: () => undefined, - subscribe: () => () => {}, -} - -/** Bind a source that disappears with the current session to an optional selector hook. */ -export function maybeObservableHook(source: HostObservable | undefined): MaybeSnapshotSelectorHook { - if (source !== undefined) return observableHook(source) - return useAbsentSnapshot -} - -function useAbsentSnapshot(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined { - // The uSES subscription must still run (hook-order stability); the absent - // source always snapshots undefined, returned explicitly. - observableHook(absentSource)(() => undefined) - return undefined -} - -/** - * The useProjection framework seat (docs/subsystems/session-projection.md), one bound - * function per provide bundle (cached by info identity — components may hold - * it across renders). Key-addressed: the key resolves a per-session value - * face off the projection store; the bound selector hook comes from the same - * per-source cache as every other kit hook, so exactly one uSES subscription - * runs per call and the subscribe reference stays stable per key. A key no - * baseline or frame has carried (or a no-session bundle) reads `undefined` — - * capability absence — keeping the hook order constant. - */ -export function projectionHook(info: SessionMaybeProvideInfo): ( - key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean, -) => unknown { - let hook = projectionHookCache.get(info) - if (hook === undefined) { - hook = (key, selector, eq) => { - // The no-session (faceless) branch binds the shared absent source so - // the caller's selector still runs over `undefined` (absence flows - // through the selector) and the uSES call count stays constant. - const useValue = observableHook(info.projections?.faceOf(key) ?? absentSource) - // Whole values are finished wire payloads (reference changes only when - // a frame or baseline lands), so the identity selector needs no - // equality function. - return useValue(selector ?? (value => value), eq) - } - projectionHookCache.set(info, hook) - } - return hook -} -const projectionHookCache = new WeakMap unknown, eq?: (a: unknown, b: unknown) => boolean, -) => unknown>() - -/** - * Root-level binding provider. It follows current selection without a key; - * per-entry identity is the outlet's adoption bookkeeping (SessionMaybeEntry): - * a blank-born incarnation adopts the first session without remounting, and - * every later transition (switch or loss) remounts like a strict entry. - */ -export function SessionMaybeProvider({ children }: { children: ReactNode }) { - const host = useHost() - const info = observableHook(host.sessions.provideInfo)(s => s) - return ( - - {children} - - ) -} - -/** SessionProvider API: render-prop body plus the no-session branch. */ -export interface SessionProviderProps { - /** No-session body (also covers a current id whose session cannot be resolved). */ - empty?: (() => ReactNode) | undefined - /** Session body; remounted per session via key={sessionId}. */ - children: (sessionId: string) => ReactNode -} - -/** - * Framework-wired session area: subscribes to the host's current provide - * source and remounts the body under `key={sessionId}` so a session switch - * rebuilds the session subtree. This dependency-inverted layer uses plain - * string ids; `PropsRuntime` applies the branded type at the component - * boundary. - */ -export function SessionProvider({ empty, children }: SessionProviderProps) { - const host = useHost() - const info = observableHook(host.sessions.provideInfo)(s => s) - const id = info.sessionId - if (id === undefined) return <>{empty?.() ?? null} - return ( - - {children(id)} - - ) -} diff --git a/packages/client/ui-renderer/src/invariant.ts b/packages/client/ui-renderer/src/invariant.ts index e1272c1daf..e3ec2db324 100644 --- a/packages/client/ui-renderer/src/invariant.ts +++ b/packages/client/ui-renderer/src/invariant.ts @@ -4,7 +4,11 @@ */ /* jscpd:ignore-start */ +/* oxlint-disable typescript/no-redundant-type-constituents -- + * `keyof SlotMap & string` is the declaration-merge key pattern: SlotMap is + * empty in this compilation unit but consumers merge concrete keys into it. */ import type { Context } from '@deepseek-ai/cordis' +import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-renderer' @@ -15,10 +19,23 @@ export const name = 'client-ui-renderer-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the package installs the render adapter and provides a - * mount callback but owns no event stream or mutable cross-plugin data relation. + * Verify that each `slots/changed` dispatch observes its mutation already + * applied to the renderer-owned slot registry. */ -const install: InvariantInstaller = () => {} +const install: InvariantInstaller = (ctx, fail) => { + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'slots/changed') return + const key: unknown = args[0] + if (typeof key !== 'string' || key === '') { + fail("'slots/changed' dispatched without a slot key argument") + return + } + const slots = ctx.get('slots') + if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) { + fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`) + } + }, { global: true }) +} /** * Register this package's invariant companion. diff --git a/packages/client/ui-renderer/tests/app.client.spec.tsx b/packages/client/ui-renderer/tests/app.client.spec.tsx index 0184198b77..14d5da75dc 100644 --- a/packages/client/ui-renderer/tests/app.client.spec.tsx +++ b/packages/client/ui-renderer/tests/app.client.spec.tsx @@ -1,9 +1,8 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render } from '@testing-library/react' import { Context } from '@deepseek-ai/cordis' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { buildRenderApp } from '../src/client/app.tsx' let runtime: SlotTestRuntime | undefined @@ -12,8 +11,6 @@ afterEach(async () => { cleanup() await runtime?.dispose() runtime = undefined - document.title = '' - vi.unstubAllEnvs() }) async function bench() { @@ -23,8 +20,9 @@ async function bench() { } describe('buildRenderApp', () => { - it('fails loud when the sessions service is unavailable', () => { - expect(() => buildRenderApp({ ctx: new Context() })).toThrow('sessions service unavailable') + it('fails loud when the slot registry is unavailable', () => { + const renderApp = buildRenderApp({ ctx: new Context() }) + expect(() => renderApp()).toThrow() }) it('renders the root slot tree', async () => { @@ -33,29 +31,4 @@ describe('buildRenderApp', () => { expect(view.getByTestId('frame')).toBeTruthy() }) - it('projects the selected durable session title', async () => { - vi.stubEnv('DSH_CLIENT_TITLE', 'Product') - document.title = 'stale title' - const b = await bench() - render(<>{b.renderApp()}) - expect(document.title).toBe('Product') - await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } }) - expect(document.title).toBe('First — Product') - await b.runtime.sessions.setCurrent(undefined) - expect(document.title).toBe('Product') - await b.runtime.sessions.add({ id: 's2' }) - expect(document.title).toBe('Product') - }) - - it('falls back when the selected id has no list row', async () => { - vi.stubEnv('DSH_CLIENT_TITLE', 'Product') - document.title = 'stale title' - const b = await bench() - await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } }) - render(<>{b.renderApp()}) - expect(document.title).toBe('First — Product') - b.runtime.sessions.list.update((draft) => { draft.current = 'ghost' as SessionId }) - await b.runtime.flush() - expect(document.title).toBe('Product') - }) }) diff --git a/packages/client/runtime/tests/invariant.client.spec.ts b/packages/client/ui-renderer/tests/invariant.client.spec.ts similarity index 84% rename from packages/client/runtime/tests/invariant.client.spec.ts rename to packages/client/ui-renderer/tests/invariant.client.spec.ts index 6de306001c..205cd460db 100644 --- a/packages/client/runtime/tests/invariant.client.spec.ts +++ b/packages/client/ui-renderer/tests/invariant.client.spec.ts @@ -1,26 +1,26 @@ /** - * Runtime invariant companion: the 'slots/changed' emission-order audit — + * Renderer invariant companion: the 'slots/changed' emission-order audit — * a fired key must already carry a bumped version (emission follows the * applied mutation), bogus payloads fail loud, foreign events pass. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import * as RuntimeInvariant from '../src/invariant.ts' -import { SlotRegistry } from '../src/client/slots.ts' +import * as RendererInvariant from '../src/invariant.ts' +import { SlotRegistry } from '../src/client/registry.ts' async function setup(): Promise { const ctx = new Context() await ctx.plugin(InvariantRegistry, { enabled: true }) - await ctx.plugin(RuntimeInvariant).await() + await ctx.plugin(RendererInvariant).await() return ctx } const emit = (ctx: Context, event: string, ...args: unknown[]): void => { - ;(ctx.emit as (event: string, ...args: unknown[]) => void)(event, ...args) + Reflect.apply(ctx.emit.bind(ctx), undefined, [event, ...args]) } -describe('runtime slots/changed invariant', () => { +describe('renderer slots/changed invariant', () => { it('passes foreign events and a legitimate mutation-then-emission sequence', async () => { const ctx = await setup() expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow() diff --git a/packages/client/runtime/tests/slots-service.client.spec.ts b/packages/client/ui-renderer/tests/registry.client.spec.ts similarity index 79% rename from packages/client/runtime/tests/slots-service.client.spec.ts rename to packages/client/ui-renderer/tests/registry.client.spec.ts index 8b4907a287..627737539b 100644 --- a/packages/client/runtime/tests/slots-service.client.spec.ts +++ b/packages/client/ui-renderer/tests/registry.client.spec.ts @@ -8,13 +8,14 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { FC } from 'react' -import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '../src/client/slots.ts' +import type { ScopedStandardSourceBinding, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotRegistry } from '../src/client/registry.ts' // Test-only slot keys (merged so the typed entries/spec faces accept them). declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { 't.host': { kind: 'single'; scope: 'root' } + 't.maybe': { kind: 'single'; scope: 'session-maybe' } 't.panel': { kind: 'single'; scope: 'session' } 't.rows': { kind: 'list'; scope: 'root' } } @@ -85,27 +86,21 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost { renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' }, }) bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C) - bench.ctx.reflect.provide('sessions', fakeSessions()) - bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) bench.erased.renderSlot('root', {}) if (host === undefined) throw new Error('renderer never received the host') return host } -/** Minimal independent Workspace list source for the renderer host contract. */ -function fakeWorkspaces() { - const state = { items: [], phase: 'ready' as const } - return { list: { getSnapshot: () => state, subscribe: () => () => undefined } } -} - -/** Minimal sessions face for the host contract (list observable + current provide projection). */ -function fakeSessions() { - const state = { ids: [], byId: {}, current: undefined as string | undefined } - const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } - return { - list: { getSnapshot: () => state, subscribe: () => () => undefined }, - currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined }, +function scopedBinding(_ctx: Context, key: string) { + const ctx = new Context() + const binding: ScopedStandardSourceBinding = { + key, + ctx, + hooks: {}, + keyedHooks: {}, + props: {}, } + return { binding, fiber: ctx.fiber } } describe("built-in 'root'", () => { @@ -404,8 +399,6 @@ describe('declaration injection', () => { const bench = await boot() let host: SlotRendererHost | undefined bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } }) - bench.ctx.reflect.provide('sessions', fakeSessions()) - bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) const disposeFrame = bench.erased.register({ name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } }, }, C) @@ -422,8 +415,10 @@ describe('declaration injection', () => { }, C) bench.erased.register({ name: 't.panel', store: handle }, C) const panelEntry = host.entriesOf('t.panel')[0] - expect(host.storeOf(panelEntry as never, 's1')).toBeDefined() + const scope = scopedBinding(bench.ctx, 's1') + expect(host.storeOf(panelEntry as never, scope.binding)).toBeDefined() expect(handle.create).toHaveBeenLastCalledWith('s1') + await scope.fiber.dispose() }) }) @@ -456,19 +451,9 @@ describe('renderer install seam', () => { const renderRoot = vi.fn(() => 'tree') bench.erased.install({ renderRoot }) bench.erased.register({ name: 'root' }, C) - bench.ctx.reflect.provide('sessions', fakeSessions()) - bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) expect(bench.erased.renderSlot('root', {})).toBe('tree') expect(renderRoot).toHaveBeenCalledTimes(1) }) - - it('fails before rendering when the Workspace object layer is absent', async () => { - const bench = await boot() - bench.erased.install({ renderRoot: () => null }) - bench.erased.register({ name: 'root' }, C) - bench.ctx.reflect.provide('sessions', fakeSessions()) - expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/) - }) }) describe('host face', () => { @@ -488,17 +473,65 @@ describe('host face', () => { expect(host.entriesOf('t.host')).toHaveLength(0) }) - it('exposes the session list and the atomic current provide projection', async () => { + it('publishes and retracts domain-owned root standard sources atomically', async () => { const bench = await boot() const host = captureHost(bench) - expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] }) - expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined }) + const source = { getSnapshot: () => 1, subscribe: () => () => undefined } + const changed = vi.fn() + host.root.subscribe(changed) + const dispose = bench.svc.provideRoot({ hooks: { sessions: source }, props: { ready: true } }) + expect(host.root.getSnapshot()).toEqual({ + key: undefined, + hooks: { sessions: source }, + keyedHooks: {}, + props: { ready: true }, + }) + expect(changed).toHaveBeenCalledOnce() + dispose() + expect(host.root.getSnapshot()).toEqual({ key: undefined, hooks: {}, keyedHooks: {}, props: {} }) + expect(changed).toHaveBeenCalledTimes(2) }) - it('exposes the independent Workspace list source', async () => { + it('rejects duplicate final root prop names without publishing a partial binding', async () => { const bench = await boot() const host = captureHost(bench) - expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' }) + const source = { getSnapshot: () => 1, subscribe: () => () => undefined } + bench.svc.provideRoot({ hooks: { feature: source } }) + const before = host.root.getSnapshot() + expect(() => bench.svc.provideRoot({ hooks: { feature: source } })) + .toThrow("duplicate root standard hook 'feature' at prop 'useFeature'") + expect(() => bench.svc.provideRoot({ keyedHooks: { feature: () => source } })) + .toThrow("duplicate root standard keyed hook 'feature' at prop 'useFeature'") + expect(() => bench.svc.provideRoot({ props: { useFeature: true } })) + .toThrow("duplicate root standard prop 'useFeature' at prop 'useFeature'") + expect(host.root.getSnapshot()).toBe(before) + }) + + it('publishes scope-adapter install and disposal revisions', async () => { + const bench = await boot() + const host = captureHost(bench) + const absent = { key: undefined, hooks: {}, keyedHooks: {}, props: {} } + const adapter = { + current: { getSnapshot: () => absent, subscribe: () => () => undefined }, + resolve: () => undefined, + } + const changed = vi.fn() + host.scopeRevision.subscribe(changed) + const owner = bench.ctx.plugin({ + name: 'session-scope-owner', + inject: ['slots'], + apply: (ctx: Context) => { ctx.slots.installScope('session', adapter) }, + }) + await owner.await() + expect(host.scopeRevision.getSnapshot()).toBe(1) + expect(changed).toHaveBeenCalledOnce() + expect(host.scope('session')).toBe(adapter) + expect(host.scope('session-maybe')).toBe(adapter) + expect(() => { bench.svc.installScope('session', adapter) }).toThrow(/already has an adapter/) + await owner.dispose() + expect(host.scopeRevision.getSnapshot()).toBe(2) + expect(changed).toHaveBeenCalledTimes(2) + expect(host.scope('session')).toBeUndefined() }) }) @@ -508,6 +541,7 @@ describe('store instance axis', () => { const bench = await boot() const host = captureHost(bench, { 't.host': { kind: 'single', scope: 'root' }, + 't.maybe': { kind: 'single', scope: 'session-maybe' }, 't.rows': { kind: 'list', scope: 'root' }, 't.panel': { kind: 'single', scope: 'session' }, }) @@ -534,13 +568,16 @@ describe('store instance axis', () => { const { handle } = fakeHandle() bench.erased.register({ name: 't.panel', store: handle }, C) const [entry] = host.entriesOf('t.panel') - const s1 = host.storeOf(entry as never, 's1') - const s2 = host.storeOf(entry as never, 's2') + const scope1 = scopedBinding(bench.ctx, 's1') + const scope2 = scopedBinding(bench.ctx, 's2') + const s1 = host.storeOf(entry as never, scope1.binding) + const s2 = host.storeOf(entry as never, scope2.binding) expect(s1).not.toBe(s2) - expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key + expect(host.storeOf(entry as never, scope1.binding)).toBe(s1) // cached per key expect(handle.create).toHaveBeenCalledWith('s1') expect(handle.create).toHaveBeenCalledWith('s2') expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/) + await Promise.all([scope1.fiber.dispose(), scope2.fiber.dispose()]) }) it('mints a fresh handle per register for the factory (exclusive) form', async () => { @@ -569,21 +606,79 @@ describe('store instance axis', () => { // resolution is covered through the cascade spec below. }) - it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => { + it('clears a materialized per-session instance with its binding lifetime', async () => { const { bench, host } = await storeBench() const { handle, created } = fakeHandle() bench.erased.register({ name: 't.panel', store: handle }, C) const [entry] = host.entriesOf('t.panel') - const s1 = host.storeOf(entry as never, 's1') + const scope = scopedBinding(bench.ctx, 's1') + const s1 = host.storeOf(entry as never, scope.binding) expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted - bench.svc.pruneStoreScope('s1') + await scope.fiber.dispose() expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1) - expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew - // Never-rendered dead session: a transient instance is created just to clear storage. - const before = created.length - bench.svc.pruneStoreScope('s-never') - expect(created.length).toBe(before + 1) - expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1) + const replacement = scopedBinding(bench.ctx, 's1') + expect(host.storeOf(entry as never, replacement.binding)).not.toBe(s1) + await replacement.fiber.dispose() + }) + + it('clears persisted state for scoped stores that were never materialized', async () => { + const { bench } = await storeBench() + const root = fakeHandle() + const scoped = fakeHandle() + bench.erased.register({ name: 't.host', store: root.handle }, C) + bench.erased.register({ name: 't.panel', store: scoped.handle }, C) + const scope = scopedBinding(bench.ctx, 's1') + + bench.svc.bindStoreScope(scope.binding) + await scope.fiber.dispose() + + expect(root.handle.create).not.toHaveBeenCalled() + expect(scoped.handle.create).toHaveBeenCalledOnce() + expect(scoped.handle.create).toHaveBeenCalledWith('s1') + expect(scoped.created[0]?.clearPersisted).toHaveBeenCalledOnce() + }) + + it('leaves scoped Store cleanup with the newest Context generation', async () => { + const { bench } = await storeBench() + const { handle, created } = fakeHandle() + bench.erased.register({ name: 't.panel', store: handle }, C) + const first = scopedBinding(bench.ctx, 's1') + const replacement = scopedBinding(bench.ctx, 's1') + + bench.svc.bindStoreScope(first.binding) + bench.svc.bindStoreScope(first.binding) + bench.svc.bindStoreScope(replacement.binding) + await first.fiber.dispose() + expect(handle.create).not.toHaveBeenCalled() + + await replacement.fiber.dispose() + expect(handle.create).toHaveBeenCalledOnce() + expect(created[0]?.clearPersisted).toHaveBeenCalledOnce() + }) + + it('clears session-maybe state through binding disposal and creates a fresh instance on reuse', async () => { + const { bench, host } = await storeBench() + bench.svc.installScope('session', { + current: { + getSnapshot: () => ({ key: undefined, hooks: {}, keyedHooks: {}, props: {} }), + subscribe: () => () => undefined, + }, + resolve: () => undefined, + }) + const { handle, created } = fakeHandle() + bench.erased.register({ name: 't.maybe', store: handle }, C) + const [entry] = host.entriesOf('t.maybe') + const scope = scopedBinding(bench.ctx, 's1') + const before = host.storeOf(entry as never, scope.binding) + + await scope.fiber.dispose() + + expect(created[0]?.clearPersisted).toHaveBeenCalledOnce() + const replacement = scopedBinding(bench.ctx, 's1') + const after = host.storeOf(entry as never, replacement.binding) + expect(after).not.toBe(before) + expect(handle.create).toHaveBeenLastCalledWith('s1') + await replacement.fiber.dispose() }) }) @@ -594,8 +689,6 @@ describe('entry-unload cascade', () => { bench.erased.install({ renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' }, }) - bench.ctx.reflect.provide('sessions', fakeSessions()) - bench.ctx.reflect.provide('workspaces', fakeWorkspaces()) // The declarer here is NOT the root occupant: root stays occupied by a // separate entry so disposing the declarer only kills its children. const disposeRoot = bench.erased.register({ name: 'root' }, C) diff --git a/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx b/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx index 68486f9430..d0887241c1 100644 --- a/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx +++ b/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { act, render } from '@testing-library/react' import { SlotCore, StaleAuthorizationError, type PropsRenderSlots, type SlotRendererHost, + type SlotScopeAdapter, type StandardSourceBinding, } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer } from '../src/client/scoped-slots.tsx' @@ -28,7 +29,20 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'> /** Passthrough host over the real core (store/session seats unused here). */ function hostOver(core: SlotCore): SlotRendererHost { - const absentInfo = { sessionId: undefined, hooks: {}, props: {} } + const absentBinding: StandardSourceBinding = { + key: undefined, + hooks: {}, + keyedHooks: {}, + props: {}, + } + const bindingSource = { + getSnapshot: () => absentBinding, + subscribe: () => () => {}, + } + const sessionAdapter: SlotScopeAdapter = { + current: bindingSource, + resolve: () => undefined, + } return { subscribe: (key, fn) => core.subscribe(key, fn), getVersion: key => core.getVersion(key), @@ -38,13 +52,9 @@ function hostOver(core: SlotCore): SlotRendererHost { specOf: key => core.specDynamic(key), isLive: entry => core.isLive(entry), storeOf: () => undefined, - sessions: { - list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, - }, - workspaces: { - list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - }, + root: bindingSource, + scopeRevision: { getSnapshot: () => 0, subscribe: () => () => {} }, + scope: () => sessionAdapter, } } diff --git a/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx b/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx index acac449728..d4360d1914 100644 --- a/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx +++ b/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx @@ -5,26 +5,31 @@ * binding, session pair, global useSessions, store pair), inject execution * point (inside component bodies, contained per entry) and parameter * derivation, and cache granularity (entry x scope key). Ledger semantics - * (declaration conflicts, store instance accounting) belong to the runtime + * (declaration conflicts, store instance accounting) belong to the renderer * SlotRegistry suite, not here. */ import { describe, expect, it, vi } from 'vitest' import { act, fireEvent, render } from '@testing-library/react' import { useEffect, useState, type ReactNode } from 'react' +import { Context } from '@deepseek-ai/cordis' import { SlotOwnershipError, StaleAuthorizationError, - type ActionsDecl, type SlotEntryDef, type SlotSpec, type StoreHandle, type StoredEntry, + type ActionsDecl, type SessionProviderComponent, type SlotEntryDef, + type SlotSpec, type StoreHandle, type StoredEntry, } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { - RenderOpts, SessionProvideInfo, SlotRendererHost, StoreInstanceLike, + RenderOpts, ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, + StandardSourceBinding, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-renderer/client' import { createSlotRenderer } from '../src/client/scoped-slots.tsx' -import { SessionProvider } from '../src/client/session-provider.tsx' type AnyProps = Record type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode -type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode; overlay?: boolean }) => ReactNode +type RenderSlotChainFn = ( + key: string, + owner: object, + opts?: { fallback?: ReactNode; fallbackOnly?: boolean; overlay?: boolean }, +) => ReactNode type DeclaredSpec = SlotSpec /** Entry literal helper: fake entries default the mandatory options bag. */ const entryOf = (partial: Omit & { options?: StoredEntry['options'] }): StoredEntry => @@ -35,7 +40,7 @@ const entryOf = (partial: Omit & { options?: StoredEntry * create(scopeKey?) + instance with clearPersisted): the machinery consumes * only the StoreInstanceLike face (bare snapshot source + baked actions), * but entry.store is typed to the full contract — the real defineStore lives - * in runtime, which UI-renderer tests must not import (dependency direction). + * in client-store, which UI-renderer tests must not import (dependency direction). */ function miniStore( init: () => T, @@ -76,11 +81,12 @@ function observable(initial: T) { /** * Behavioral SlotRendererHost fake: registration mutates entries, bumps the * key version, and notifies synchronously (batching semantics belong to the - * runtime host, not this package's outlets). Store instances resolve through + * registry host, not this package's outlets). Store instances resolve through * the entry's real handle, cached per (entry x scope key) like the real - * ledger; session cells are identity-stable per id. + * ledger; session bindings are identity-stable per id. */ function makeHost() { + const scopeCtx = new Context() const entries = new Map() const specs = new Map() const versions = new Map() @@ -90,11 +96,31 @@ function makeHost() { const storeCache = new Map>() const list = observable<{ ids: string[] }>({ ids: [] }) const workspaces = observable<{ ids: string[] }>({ ids: [] }) - const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} } - const provide = observable(absentInfo) + const absentBinding: StandardSourceBinding = { + key: undefined, + hooks: { session: undefined }, + keyedHooks: {}, + props: { sessionId: undefined }, + } + const currentBinding = observable(absentBinding) let currentId: string | undefined - const infos = new Map() + const bindings = new Map() const sessionSources = new Map>>() + const root = observable({ + key: undefined, + hooks: { sessions: list, workspaces }, + keyedHooks: {}, + props: {}, + }) + const sessionAdapter: SlotScopeAdapter = { + current: currentBinding, + resolve: key => bindings.get(key), + renderArea: (binding, { empty, children }) => binding.key === undefined + ? <>{empty?.() ?? null} + : <>{children}, + } + const scopeRevision = observable(0) + let activeScopeAdapter = sessionAdapter const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) @@ -133,40 +159,38 @@ function makeHost() { }, specOf: key => specs.get(key), isLive: entry => live.has(entry), - storeOf: (entry, scopeKey) => { + storeOf: (entry, scopeBinding) => { if (entry.store === undefined) return undefined let perScope = storeCache.get(entry) if (!perScope) { perScope = new Map() storeCache.set(entry, perScope) } - const cacheKey = scopeKey ?? '' + const cacheKey = scopeBinding?.key ?? '' let instance = perScope.get(cacheKey) if (!instance) { // Fake entries always carry engine handles (never factories), and the // engine create() takes the scope key (persist suffixing). const handle = entry.store as { create(scopeKey?: string): StoreInstanceLike } - instance = handle.create(scopeKey) + instance = handle.create(scopeBinding?.key) perScope.set(cacheKey, instance) } return instance }, - sessions: { - list, - provideInfo: provide, - }, - workspaces: { list: workspaces }, + root, + scopeRevision, + scope: () => activeScopeAdapter, } return { host, list, workspaces, - // Driver surface: set(id) publishes the resolved bundle (or the absent - // projection) through the provide source. + // Driver surface: set(id) publishes the resolved binding (or the absent + // projection) through the scope adapter. current: { set: (id: string | undefined) => { currentId = id - provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding) }, }, declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) }, @@ -188,33 +212,46 @@ function makeHost() { bump(key) } }, - addSession: (id: string, initial: unknown = { sid: id }): SessionProvideInfo => { - // Bare source per bundle (identity-stable): the machinery binds useSession from it. + addSession: (id: string, initial: unknown = { sid: id }): ScopedStandardSourceBinding => { + // Bare source per binding (identity-stable): the machinery binds useSession from it. const session = observable(initial) - const info: SessionProvideInfo = { - sessionId: id, + const binding: ScopedStandardSourceBinding = { + key: id, + ctx: scopeCtx, hooks: { session }, - props: {}, + keyedHooks: {}, + props: { sessionId: id }, } sessionSources.set(id, session) - infos.set(id, info) - if (currentId === id) provide.set(info) - return info + bindings.set(id, binding) + if (currentId === id) currentBinding.set(binding) + return binding }, setSession: (id: string, snapshot: unknown) => { const source = sessionSources.get(id) if (source === undefined) throw new Error(`unknown test session: ${id}`) source.set(snapshot) }, + replaceScope: (adapter: SlotScopeAdapter) => { + activeScopeAdapter = adapter + scopeRevision.set(scopeRevision.getSnapshot() + 1) + }, } } type Fake = ReturnType /** Mount a root entry whose component renders `body` with its kit renderSlot. */ -function mountRoot(h: Fake, children: Record, body: (renderSlot: RenderSlotFn) => ReactNode) { +function mountRoot( + h: Fake, + children: Record, + body: (renderSlot: RenderSlotFn, SessionProvider: SessionProviderComponent) => ReactNode, +) { const dispose = h.add('root', { - component: (props: { renderSlot: RenderSlotFn }) => <>{body(props.renderSlot)}, + component: (props: { + renderSlot: RenderSlotFn + SessionProvider: SessionProviderComponent + }) => <>{body(props.renderSlot, props.SessionProvider)}, children, }) const renderer = createSlotRenderer() @@ -225,6 +262,7 @@ function mountRoot(h: Fake, children: Record, body: (rende const SINGLE_ROOT: DeclaredSpec = { kind: 'single', scope: 'root' } const SINGLE_SESSION: DeclaredSpec = { kind: 'single', scope: 'session' } const CHAIN_ROOT: DeclaredSpec = { kind: 'chain', scope: 'root' } +const CHAIN_SESSION: DeclaredSpec = { kind: 'chain', scope: 'session' } /** Chain entry literal: top-level select, priority in the options bag (the StoredEntry chain shape). */ const chainEntryOf = (partial: { @@ -566,6 +604,31 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => { expect(mounted).toHaveBeenCalledTimes(1) }) + it('keeps an explicit fallback-only strict chain mounted until its Session scope exists', () => { + const h = makeHost() + h.declare('k.chain', CHAIN_SESSION) + h.addSession('s1') + const select = vi.fn(() => null) + h.add('k.chain', chainEntryOf({ component: () => never, select })) + let fallbackOnly = true + const { view } = mountChainRoot(h, { 'k.chain': CHAIN_SESSION }, + renderSlotChain => renderSlotChain( + 'k.chain', + {}, + { fallback: , fallbackOnly, overlay: true }, + )) + const input = view.getByRole('textbox', { name: 'resident' }) + expect(select).not.toHaveBeenCalled() + + fallbackOnly = false + act(() => { + h.current.set('s1') + h.add('root', { component: () => null }) + }) + expect(view.getByRole('textbox', { name: 'resident' })).toBe(input) + expect(select).toHaveBeenCalledOnce() + }) + it('leaves non-overlay chains on the unmount path: a takeover discards fallback state', () => { const h = makeHost() h.declare('k.chain', CHAIN_ROOT) @@ -656,9 +719,9 @@ describe('standard-kit synthesis', () => { return null }, }) - mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => ( + mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => ( empty}> - {() => renderSlot('k.session', {})} + {renderSlot('k.session', {})} )) act(() => { h.current.set('s1') }) @@ -698,11 +761,11 @@ describe('standard-kit synthesis', () => { return {String(useTurnData('tail'))} }, }) - const { view } = mountRoot(h, { 'k.session': sessionSpec }, renderSlot => ( - {() => <> + const { view } = mountRoot(h, { 'k.session': sessionSpec }, (renderSlot, SessionProvider) => ( + <> {renderSlot('k.session', { label: 'one' }, { hookContext: 1 })} {renderSlot('k.session', { label: 'two' }, { hookContext: 2 })} - } + )) act(() => { h.current.set('s1') }) @@ -746,11 +809,11 @@ describe('standard-kit synthesis', () => { h.add('root', { component: (props: AnyProps) => { rootSeen.push(props) - const Provider = props['SessionProvider'] as typeof SessionProvider + const Provider = props['SessionProvider'] as SessionProviderComponent const renderSlot = props['renderSlot'] as RenderSlotFn return ( empty}> - {() => renderSlot('k.session', {})} + {renderSlot('k.session', {})} ) }, @@ -773,15 +836,15 @@ describe('standard-kit synthesis', () => { expect(seen2.at(-1)!['SessionProvider']).toBeUndefined() }) - it('renders nothing for a strict session slot while no session is current', () => { - // Strict session entries decline (render null) without a session; the - // loud path is reserved for a missing root binding provider. + it('fails loud for a strict session slot while no session is current', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) h.add('k.session', { component: () => x }) - const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, - renderSlot => renderSlot('k.session', {})) - expect(view.container.querySelector('b')).toBeNull() + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(() => mountRoot(h, { 'k.session': SINGLE_SESSION }, + renderSlot => renderSlot('k.session', {}))) + .toThrow("strict session slot 'k.session' rendered without a scope binding") + spy.mockRestore() }) it('delivers the store pair for store-declaring entries and writes through baked actions', () => { @@ -822,8 +885,8 @@ describe('standard-kit synthesis', () => { }, store: handle, }) - const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => ( - {() => renderSlot('k.session', {})} + const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} )) act(() => { h.current.set('s1') }) act(() => { setDraft('draft-one') }) @@ -877,8 +940,8 @@ describe('inject: execution point, parameter derivation, cache granularity', () component: ({ sid }: { sid?: string }) => {sid}, inject: inject, }) - const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => ( - {() => renderSlot('k.session', {})} + const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} )) act(() => { h.current.set('s1') }) expect(view.container.textContent).toBe('s1') @@ -912,9 +975,9 @@ describe('inject: execution point, parameter derivation, cache granularity', () inject: sessionInject, store: handle, }) - mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, renderSlot => <> + mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => <> {renderSlot('k.single', {})} - {() => renderSlot('k.session', {})} + {renderSlot('k.session', {})} ) act(() => { h.current.set('s1') }) // The inject-received actions are the same baked callbacks the component @@ -1029,4 +1092,24 @@ describe('session-maybe adoption identity', () => { act(() => { h.current.set('s1') }) expect(view.container.textContent).toBe('s1#1') }) + + it('rebinds mounted scope consumers when the installed adapter changes', () => { + const h = makeHost() + const { view } = mountMaybeCounter(h) + expect(view.container.textContent).toBe('blank#1') + const binding: ScopedStandardSourceBinding = { + key: 'replacement', + ctx: new Context(), + hooks: { session: observable({ sid: 'replacement' }) }, + keyedHooks: {}, + props: { sessionId: 'replacement' }, + } + act(() => { + h.replaceScope({ + current: observable(binding), + resolve: key => key === binding.key ? binding : undefined, + }) + }) + expect(view.container.textContent).toBe('replacement#1') + }) }) diff --git a/packages/client/ui-renderer/tests/session-provider.client.spec.tsx b/packages/client/ui-renderer/tests/session-provider.client.spec.tsx index e726577725..8f9398d17b 100644 --- a/packages/client/ui-renderer/tests/session-provider.client.spec.tsx +++ b/packages/client/ui-renderer/tests/session-provider.client.spec.tsx @@ -1,11 +1,17 @@ // @vitest-environment jsdom -import { useEffect, useRef } from 'react' +import { Fragment, useEffect, useRef } from 'react' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { act, render } from '@testing-library/react' -import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionProvideInfo, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { + SessionProviderComponent, StoredEntry, +} from '@deepseek-ai/dsh-client-ui-slots' +import type { + ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding, +} from '@deepseek-ai/dsh-client-ui-renderer/client' import { createSlotRenderer } from '../src/client/scoped-slots.tsx' -import { SessionProvider } from '../src/client/session-provider.tsx' + +type SessionBinding = ScopedStandardSourceBinding function observable(initial: T) { let value = initial @@ -18,19 +24,52 @@ function observable(initial: T) { } /** - * Minimal host: SessionProvider only reads sessions.provideInfo, but it must + * Minimal host: SessionProvider only reads the Session scope adapter, but it must * render inside the renderer tree (HostContext), so the harness mounts a real * root entry whose body is the test's render-prop provider. */ -function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) { - const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } - const provide = observable(absentInfo) +function makeHost( + bodies: { + root: ( + rp: (key: string, owner: object) => React.ReactNode, + SessionProvider: SessionProviderComponent, + ) => React.ReactNode + }, + options: { installRenderArea?: boolean } = {}, +) { + const scopeCtx = new Context() + const absentBinding: StandardSourceBinding = { + key: undefined, + hooks: { session: undefined }, + keyedHooks: {}, + props: { sessionId: undefined }, + } + const currentBinding = observable(absentBinding) let currentId: string | undefined - const infos = new Map() + const bindings = new Map() const sessionEntries: StoredEntry[] = [] + const root = observable({ + key: undefined, + hooks: {}, + keyedHooks: {}, + props: {}, + }) + const sessionAdapter: SlotScopeAdapter = { + current: currentBinding, + resolve: key => bindings.get(key), + ...(options.installRenderArea === false + ? {} + : { + renderArea: (binding, { empty, children }) => binding.key === undefined + ? <>{empty?.() ?? null} + : {children}, + }), + } const rootEntry: StoredEntry = { - component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) => - <>{bodies.root(props.renderSlot)}, + component: (props: { + renderSlot: (key: string, owner: object) => React.ReactNode + SessionProvider: SessionProviderComponent + }) => <>{bodies.root(props.renderSlot, props.SessionProvider)}, options: {}, children: { 'k.session': { kind: 'single', scope: 'session' } }, } @@ -45,37 +84,37 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined, isLive: () => true, storeOf: () => undefined, - sessions: { - list: observable({ ids: [] }), - provideInfo: provide, - }, - workspaces: { list: observable({ items: [] }) }, + root, + scopeRevision: observable(0), + scope: () => sessionAdapter, } return { host, - // Driver surface: set(id) publishes the resolved bundle (or the absent - // projection) through the provide source. + // Driver surface: set(id) publishes the resolved binding (or the absent + // projection) through the scope adapter. current: { set: (id: string | undefined) => { currentId = id - provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding) }, }, addSession: (id: string) => { - // Bare source per bundle (identity-stable): the machinery binds useSession from it. - const info: SessionProvideInfo = { - sessionId: id, + // Bare source per binding (identity-stable): the machinery binds useSession from it. + const binding: SessionBinding = { + key: id, + ctx: scopeCtx, hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } }, - props: {}, + keyedHooks: {}, + props: { sessionId: id }, } - infos.set(id, info) - if (currentId === id) provide.set(info) - return info + bindings.set(id, binding) + if (currentId === id) currentBinding.set(binding) + return binding }, - /** Swap one session's bundle in place (roster-change stand-in); republish when current. */ - replaceSession: (info: SessionProvideInfo) => { - infos.set(info.sessionId, info) - if (currentId === info.sessionId) provide.set(info) + /** Swap one session's binding in place (roster-change stand-in); republish when current. */ + replaceSession: (binding: SessionBinding) => { + bindings.set(binding.key, binding) + if (currentId === binding.key) currentBinding.set(binding) }, registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) }, } @@ -84,9 +123,9 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea describe('SessionProvider', () => { it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => { const h = makeHost({ - root: () => ( + root: (_renderSlot, SessionProvider) => ( empty}> - {id =>
{id}
} +
session
), }) @@ -94,14 +133,14 @@ describe('SessionProvider', () => { const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}) expect(view.container.textContent).toBe('empty') act(() => { h.current.set('s1') }) - expect(view.container.textContent).toBe('s1') + expect(view.container.textContent).toBe('session') act(() => { h.current.set('ghost') }) // listed nowhere: cell() misses expect(view.container.textContent).toBe('empty') }) it('renders null empty state when the empty prop is omitted', () => { const h = makeHost({ - root: () => {id => {id}}, + root: (_renderSlot, SessionProvider) => session, }) const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}) expect(view.container.textContent).toBe('') @@ -118,7 +157,13 @@ describe('SessionProvider', () => { return
{id}
} const h = makeHost({ - root: () => {id => }, + root: (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} + ), + }) + h.registerSession({ + component: (props: { sessionId?: string }) => , + options: {}, }) h.addSession('s1') h.addSession('s2') @@ -135,7 +180,9 @@ describe('SessionProvider', () => { it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => { const seen: Record[] = [] const h = makeHost({ - root: renderSlot => {() => renderSlot('k.session', {})}, + root: (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} + ), }) h.addSession('s1') h.addSession('s2') @@ -160,7 +207,9 @@ describe('SessionProvider', () => { it('republishes a mounted session entry when its provide bundle changes under the same id', () => { const seen: unknown[] = [] const h = makeHost({ - root: renderSlot => {() => renderSlot('k.session', {})}, + root: (renderSlot, SessionProvider) => ( + {renderSlot('k.session', {})} + ), }) const original = h.addSession('s1') h.registerSession({ @@ -173,17 +222,17 @@ describe('SessionProvider', () => { render(<>{createSlotRenderer().renderRoot(h.host, {})}) act(() => { h.current.set('s1') }) expect(seen.at(-1)).toBeUndefined() - // A provider-roster change rematerializes the bundle; the provide source + // A provider-roster change rematerializes the binding; the scope source // must carry it to already-mounted entries without a selection change. act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) }) expect(seen.at(-1)).toBe('now-live') }) - it('fails loud when mounted outside the renderer tree (no host channel)', () => { + it('fails loud when the Session scope owner omits its area renderer', () => { + const h = makeHost({ root: () => null }, { installRenderArea: false }) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - expect(() => render( - {id => {id}}, - )).toThrow(/outside the installed renderer tree/) + expect(() => render(<>{createSlotRenderer().renderRoot(h.host, {})})) + .toThrow(/does not provide its area renderer/) spy.mockRestore() }) }) diff --git a/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx b/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx index c80fdf25dc..bfc9ec1a85 100644 --- a/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx +++ b/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx @@ -9,7 +9,9 @@ import type { ReactNode } from 'react' import { StaleAuthorizationError, type SlotEntryDef, type SlotSpec, type StoredEntry, } from '@deepseek-ai/dsh-client-ui-slots' -import type { RenderOpts, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { + RenderOpts, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding, +} from '@deepseek-ai/dsh-client-ui-renderer/client' import { createSlotRenderer } from '../src/client/scoped-slots.tsx' type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode @@ -21,7 +23,20 @@ function makeHost() { const versions = new Map() const subs = new Map void>>() const live = new Set() - const absentInfo = { sessionId: undefined, hooks: {}, props: {} } + const absentBinding: StandardSourceBinding = { + key: undefined, + hooks: {}, + keyedHooks: {}, + props: {}, + } + const bindingSource = { + getSnapshot: () => absentBinding, + subscribe: () => () => {}, + } + const sessionAdapter: SlotScopeAdapter = { + current: bindingSource, + resolve: () => undefined, + } const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) for (const fn of [...(subs.get(key) ?? [])]) fn() @@ -42,13 +57,9 @@ function makeHost() { specOf: () => ({ kind: 'single', scope: 'root' }), isLive: entry => live.has(entry), storeOf: () => undefined, - sessions: { - list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, - }, - workspaces: { - list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - }, + root: bindingSource, + scopeRevision: { getSnapshot: () => 0, subscribe: () => () => {} }, + scope: () => sessionAdapter, } return { host, diff --git a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx index a966938468..ef254bff8d 100644 --- a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx +++ b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx @@ -2,9 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup } from '@testing-library/react' import { Context } from '@deepseek-ai/cordis' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime' -import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotRegistry } from '../src/client/registry.ts' +import type { SlotScopeAdapter, StandardSourceBinding } from '../src/client/index.ts' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-renderer' import * as UiRenderer from '../src/client/index.ts' @@ -17,16 +16,30 @@ afterEach(() => { document.body.innerHTML = '' }) -const stabilize: Stabilizer = async (fn) => { await act(async () => { await fn() }) } +const stabilize = async (fn: () => void | Promise): Promise => { + await act(async () => { await fn() }) +} async function bench() { const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - const slots = ctx.get('slots') as SlotRegistry - ctx.provide('sessions', new TestSessions(stabilize, ctx)) - ctx.provide('workspaces', new TestWorkspaces(stabilize)) const fiber = ctx.plugin({ inject: [...UiRenderer.inject], apply: UiRenderer.apply }) await fiber.await() + const slots = ctx.get('slots') as SlotRegistry + const absentBinding: StandardSourceBinding = { + key: undefined, + hooks: {}, + keyedHooks: {}, + props: {}, + } + const current = { + getSnapshot: () => absentBinding, + subscribe: () => () => {}, + } + const adapter: SlotScopeAdapter = { + current, + resolve: () => undefined, + } + slots.installScope('session', adapter) return { ctx, slots, fiber } } diff --git a/packages/client/ui-renderer/tests/use-projection.client.spec.tsx b/packages/client/ui-renderer/tests/use-projection.client.spec.tsx index 06db9082d2..a34d139a28 100644 --- a/packages/client/ui-renderer/tests/use-projection.client.spec.tsx +++ b/packages/client/ui-renderer/tests/use-projection.client.spec.tsx @@ -4,16 +4,21 @@ * docs/subsystems/session-projection.md): the fifth * framework hook seat rides the same provide channel as useSession — a * session slot component receives `useProjection` in its kit, key-addressed - * over the bundle's projection face; unresolved keys (no value, no face, no - * session) uniformly read `undefined`; live value changes re-render; the - * selector overload runs over the whole value. + * over the binding's projection source family; unresolved keys and absent + * sessions read `undefined`; live value changes re-render; the selector + * overload runs over the whole value. */ import { describe, expect, it } from 'vitest' import { act, render } from '@testing-library/react' -import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' -import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { Context } from '@deepseek-ai/cordis' +import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import type { + ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding, +} from '@deepseek-ai/dsh-client-ui-renderer/client' import { createSlotRenderer } from '../src/client/scoped-slots.tsx' +type SessionBinding = ScopedStandardSourceBinding + function observable(initial: T) { let value = initial const subs = new Set<() => void>() @@ -27,25 +32,51 @@ function observable(initial: T) { type UseProjectionProp = (key: string, selector?: (v: unknown) => unknown) => unknown function makeHost() { - const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } - const provide = observable(absentInfo) + const scopeCtx = new Context() + const absentBinding: StandardSourceBinding = { + key: undefined, + hooks: { session: undefined }, + keyedHooks: { projection: undefined }, + props: { sessionId: undefined }, + } + const currentBinding = observable(absentBinding) const cells = new Map>>() - /** Store-parallel face: always defined per key; an unseen key snapshots undefined. */ + /** Store-parallel source family: an unseen key snapshots undefined. */ const absent = { getSnapshot: () => undefined, subscribe: () => () => {} } const sessionEntries: StoredEntry[] = [] - let withFace = true + const bindings = new Map() const rootEntry: StoredEntry = { component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) => <>{props.renderSlot('k.session', {})}, options: {}, children: { 'k.session': { kind: 'single', scope: 'session' } }, } - const info = (id: string): SessionMaybeProvideInfo => ({ - sessionId: id, - hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } }, + const binding = (id: string): SessionBinding => { + const cached = bindings.get(id) + if (cached !== undefined) return cached + const value: SessionBinding = { + key: id, + ctx: scopeCtx, + hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } }, + keyedHooks: { projection: key => cells.get(key) ?? absent }, + props: { sessionId: id }, + } + bindings.set(id, value) + return value + } + const root = observable({ + key: undefined, + hooks: {}, + keyedHooks: {}, props: {}, - ...(withFace ? { projections: { faceOf: (key: string) => cells.get(key) ?? absent } } : {}), }) + const sessionAdapter: SlotScopeAdapter = { + current: currentBinding, + resolve: binding, + renderArea: (scopeBinding, { empty, children }) => scopeBinding.key === undefined + ? <>{empty?.() ?? null} + : <>{children}, + } const host: SlotRendererHost = { subscribe: () => () => {}, getVersion: () => 0, @@ -57,19 +88,19 @@ function makeHost() { specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined, isLive: () => true, storeOf: () => undefined, - sessions: { - list: observable({ ids: [] }), - provideInfo: provide, - }, - workspaces: { list: observable({ items: [] }) }, + root, + scopeRevision: observable(0), + scope: () => sessionAdapter, } return { host, cells, - // Same driver surface as before the atomic provide source: set(id) - // publishes the resolved bundle (or the absent projection) through it. - current: { set: (id: string | undefined) => { provide.set(id === undefined ? absentInfo : info(id)) } }, - dropFace: () => { withFace = false }, + // The driver publishes the resolved binding or the absent projection. + current: { + set: (id: string | undefined) => { + currentBinding.set(id === undefined ? absentBinding : binding(id)) + }, + }, registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) }, } } @@ -90,8 +121,8 @@ describe('useProjection standard-kit delivery', () => { }, options: {}, }) + h.current.set('s1') render(<>{createSlotRenderer().renderRoot(h.host, {})}) - act(() => { h.current.set('s1') }) expect(reads.at(-1)).toEqual({ marks: { marks: ['a'] }, ghost: undefined }) // Live change re-renders with the new whole value. act(() => { cell.set({ marks: ['a', 'b'] }) }) @@ -110,25 +141,8 @@ describe('useProjection standard-kit delivery', () => { }, options: {}, }) + h.current.set('s1') render(<>{createSlotRenderer().renderRoot(h.host, {})}) - act(() => { h.current.set('s1') }) expect(reads.slice(-2)).toEqual([2, 'absent']) }) - - it('treats a bundle without the projections face as all-absent (capability absence)', () => { - const h = makeHost() - h.cells.set('test/marks', observable({ marks: ['a'] })) - h.dropFace() - const reads: unknown[] = [] - h.registerSession({ - component: (props: { useProjection: UseProjectionProp }) => { - reads.push(props.useProjection('test/marks')) - return null - }, - options: {}, - }) - render(<>{createSlotRenderer().renderRoot(h.host, {})}) - act(() => { h.current.set('s1') }) - expect(reads.at(-1)).toBeUndefined() - }) }) diff --git a/packages/client/ui-renderer/tsconfig.json b/packages/client/ui-renderer/tsconfig.json index bae7af7101..e36243012c 100644 --- a/packages/client/ui-renderer/tsconfig.json +++ b/packages/client/ui-renderer/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../ui-slots" }, - { - "path": "../runtime" - }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml new file mode 100644 index 0000000000..f5a325ce8c --- /dev/null +++ b/packages/client/ui-session/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-session/README.md +README.md: d18dec6232ff43112af761e27104cc50db84b169 +README.zh.md: 4c72c767a3a2cddef7dc9793b88425b3d20a4d90 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md new file mode 100644 index 0000000000..d18dec6232 --- /dev/null +++ b/packages/client/ui-session/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-session + +English | [中文](README.zh.md) + +React and Slot adapter for Session Controller state. It contributes Session list and pending-interaction hooks at root scope, materializes per-Session hooks and props, and owns the standard `SessionProvider` rendering behavior without taking ownership of Session transport or lifecycle state. + +## Model Experience + +None, as this package adapts browser-side Session state and registers nothing model-facing. + +#### KV Cache effect + +None; Session selectors and Slot scopes do not assemble model requests. + +## Known Limitations and Deferred Work + +- **Pending interactions are process-local projections** — the owning Remote waterfall must replay an outstanding request after a browser reconnect. diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md new file mode 100644 index 0000000000..4c72c767a3 --- /dev/null +++ b/packages/client/ui-session/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-client-ui-session + +[English](README.md) | 中文 + +面向 Session Controller 状态的 React 与 Slot adapter。本包在 root scope 提供 Session list 和 pending-interaction hook,物化逐 Session hook 与 prop,并拥有标准 `SessionProvider` 渲染行为,但不接管 Session transport 或 lifecycle 状态。 + +## 模型体验 + +无,因为本包适配浏览器侧 Session 状态,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;Session selector 与 Slot scope 不会组装模型请求。 + +## 已知限制与暂缓事项 + +- **Pending interaction 是进程本地投影**——浏览器重连后,所属 Remote waterfall 必须重放仍未完成的请求。 diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json new file mode 100644 index 0000000000..34087f6979 --- /dev/null +++ b/packages/client/ui-session/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-session", + "description": "Session Controller adapter for React and session-scoped slots", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-session" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-ui-renderer" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts new file mode 100644 index 0000000000..b720041f4d --- /dev/null +++ b/packages/client/ui-session/src/client/index.ts @@ -0,0 +1,525 @@ +/** Session Controller adapter for React selector hooks and Slot scope data. */ +import { Service, type Context } from '@deepseek-ai/cordis' +import type { + ISessions, + SessionBinding, + SessionListState, + SessionSnapshot, + UseProjection, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots' +import type { + HostObservable, + KeyedStandardSource, + MaybeSnapshotSelectorHook, + RootStandardSourceContribution, + ScopedStandardSourceBinding, + SlotScopeAdapter, + SnapshotSelectorHook, + StandardSourceBinding, +} from '@deepseek-ai/dsh-client-ui-slots' +// Type-only service merge for ctx.slots. +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { renderSessionArea } from './session-provider.tsx' + +/** Selector hook over the Session Controller list and current selection. */ +export type UseSessions = SnapshotSelectorHook +/** Selector hook over one Session's lifecycle and control state. */ +export type SessionSnapshotSelector = SnapshotSelectorHook +/** Public name for the Session lifecycle selector hook. */ +export type UseSession = SessionSnapshotSelector + +/** Common identity carried by every Session-scoped pending interaction. */ +export interface SessionPendingInteractionBase { + /** Opaque request identity; a replacement request must use a new key. */ + readonly key: string + /** Domain-owned presentation discriminator. */ + readonly kind: string + /** Session whose UI can answer this interaction. */ + readonly sessionId: SessionId +} + +/** Declaration-merged roster of domain-owned pending interaction values. */ +export interface SessionPendingInteractionMap {} + +/** Every pending interaction contributed by the assembled Client. */ +export type SessionPendingInteraction = + [keyof SessionPendingInteractionMap] extends [never] + ? SessionPendingInteractionBase + : SessionPendingInteractionMap[keyof SessionPendingInteractionMap] + +/** Current effective pending interaction by Session. */ +export type SessionPendingInteractionSnapshot = ReadonlyMap +/** Selector hook over Session-scoped pending interactions. */ +export type UseSessionPendingInteraction = SnapshotSelectorHook + +/** Publish one pending interaction and define how plugin teardown delegates it. */ +export type PendingInteractionPublisher = ( + interaction: T, + delegate: () => Promise, +) => () => void + +interface PendingInteractionEntry { + readonly interaction: T + readonly delegate: () => Promise +} + +class PendingInteractionDomain { + private readonly values = new Map>() + + constructor( + readonly precedence: (interaction: T) => number, + private readonly changed: () => void, + ) {} + + valuesSnapshot(): readonly T[] { + return [...this.values.values()].map(entry => entry.interaction) + } + + publish(interaction: T, delegate: () => Promise): () => void { + if (this.values.has(interaction.key)) { + throw new Error(`ui-session: duplicate pending interaction key '${interaction.key}'`) + } + this.values.set(interaction.key, { interaction, delegate }) + this.changed() + let active = true + return () => { + if (!active) return + active = false + if (!this.values.delete(interaction.key)) return + this.changed() + } + } + + /** Remove every pending value and return the operations that settle their owners. */ + release(): readonly (() => Promise)[] { + const delegates = [...this.values.values()].map(entry => entry.delegate) + this.values.clear() + return delegates + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Session list and current selection. */ + useSessions: UseSessions + /** Pending user interaction presented by a Session-scoped UI consumer. */ + useSessionPendingInteraction: UseSessionPendingInteraction + } + + interface SessionStandardProps { + /** Current Session lifecycle and control state. */ + useSession: SessionSnapshotSelector + /** Current Session identity. */ + sessionId: SessionId + /** Host-computed projection values addressed by projection key. */ + useProjection: UseProjection + } + + interface SessionMaybeStandardProps { + /** Current Session state, absent while no Session is selected. */ + useSession: MaybeSnapshotSelectorHook + /** Current Session identity, absent while no Session is selected. */ + sessionId: SessionId | undefined + /** Host-computed projection values; every key is absent without a Session. */ + useProjection: UseProjection + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Session Controller adapter and session-scoped source registry. */ + uiSession: UiSession + } +} + +type SessionSourceRoster = readonly string[] | undefined +type StandardMemberKind = 'hook' | 'keyed hook' | 'prop' + +type SessionSourceRecord = + Roster extends readonly string[] ? Readonly> : never + +/** Bare values produced by one Session-scoped source contribution. */ +export interface SessionSourceContribution< + Hooks extends SessionSourceRoster = SessionSourceRoster, + KeyedHooks extends SessionSourceRoster = SessionSourceRoster, + Props extends SessionSourceRoster = SessionSourceRoster, +> { + readonly hooks?: SessionSourceRecord> + readonly keyedHooks?: SessionSourceRecord + readonly props?: SessionSourceRecord +} + +/** Static roster and per-Session resolver for one standard-props contribution. */ +export interface SessionSourceDescriptor< + Hooks extends SessionSourceRoster = SessionSourceRoster, + KeyedHooks extends SessionSourceRoster = SessionSourceRoster, + Props extends SessionSourceRoster = SessionSourceRoster, +> { + readonly hooks?: Hooks + readonly keyedHooks?: KeyedHooks + readonly props?: Props + /** + * Resolve every declared member for one Session binding. + * @param binding - Controller-owned Session binding. + * @returns all declared bare sources and stable props. + */ + resolve(binding: SessionBinding): SessionSourceContribution< + NoInfer, + NoInfer, + NoInfer + > +} + +interface RuntimeSessionSourceContribution { + readonly hooks?: Readonly>> + readonly keyedHooks?: Readonly> + readonly props?: Readonly> +} + +interface RuntimeSessionSourceDescriptor { + readonly hooks?: readonly string[] + readonly keyedHooks?: readonly string[] + readonly props?: readonly string[] + resolve(binding: SessionBinding): RuntimeSessionSourceContribution +} + +type RuntimePendingDomain = PendingInteractionDomain + +interface MaterializedBinding { + readonly owner: SessionBinding + readonly value: ScopedStandardSourceBinding + readonly release: () => void +} + +const BUILTIN_SOURCE = { + hooks: ['session'], + keyedHooks: ['projection'], + props: ['sessionId'], + resolve: binding => ({ + hooks: { session: binding.session }, + keyedHooks: { projection: key => binding.session.projections.faceOf(key) }, + props: { sessionId: binding.sessionId }, + }), +} satisfies SessionSourceDescriptor< + readonly ['session'], + readonly ['projection'], + readonly ['sessionId'] +> + +/** Session-scoped source roster and renderer adapter. */ +export class UiSession extends Service { + private readonly descriptors: RuntimeSessionSourceDescriptor[] = [ + BUILTIN_SOURCE, + ] + private bindings = new Map() + private absent: StandardSourceBinding + private currentBinding: StandardSourceBinding + private readonly currentListeners = new Set<() => void>() + private readonly pendingDomains: RuntimePendingDomain[] = [] + private pendingSnapshot: ReadonlyMap = new Map() + private readonly pendingListeners = new Set<() => void>() + /** Root source of pending UI interactions, independent from Controller snapshots. */ + readonly pendingInteractions: HostObservable = { + getSnapshot: () => this.pendingSnapshot, + subscribe: (listener) => { + this.pendingListeners.add(listener) + return () => { this.pendingListeners.delete(listener) } + }, + } + /** Renderer-facing adapter for `session` and `session-maybe` scopes. */ + readonly adapter: SlotScopeAdapter + + /** + * @param ctx - Client root context. + * @param sessions - Controller-owned Session object layer. + */ + constructor( + ctx: Context, + private readonly sessions: ISessions, + ) { + super(ctx, 'uiSession') + this.absent = this.materializeAbsent() + this.currentBinding = this.resolveCurrent() + this.adapter = { + current: { + getSnapshot: () => this.currentBinding, + subscribe: (listener) => { + this.currentListeners.add(listener) + return () => { this.currentListeners.delete(listener) } + }, + }, + resolve: key => this.resolve(key as SessionId), + renderArea: renderSessionArea, + } + + ctx.effect(() => { + const disposeList = sessions.list.subscribe(() => { this.publishCurrent() }) + return () => { + disposeList() + const records = [...this.bindings.values()] + this.bindings.clear() + for (const record of records) record.release() + } + }, 'ui-session: Session binding projection') + } + + /** + * Register one Session-scoped standard-source contribution. + * @param descriptor - static member roster and per-binding resolver. + * @returns disposer owned by the caller's Cordis fiber. + */ + provide< + const Hooks extends SessionSourceRoster = undefined, + const KeyedHooks extends SessionSourceRoster = undefined, + const Props extends SessionSourceRoster = undefined, + >(descriptor: SessionSourceDescriptor): () => void { + const runtimeDescriptor = descriptor as unknown as RuntimeSessionSourceDescriptor + const dispose = this.ctx.effect(() => { + this.descriptors.push(runtimeDescriptor) + try { + this.rebuildBindings() + } catch (error) { + this.descriptors.pop() + throw error + } + return () => { + const index = this.descriptors.indexOf(runtimeDescriptor) + this.descriptors.splice(index, 1) + this.rebuildBindings() + } + }, 'uiSession.provide()') + return () => { void dispose() } + } + + /** + * Register one pending-interaction domain and return its publication function. + * Domain teardown first removes its visible values, then delegates and awaits + * every still-active owner request. + * @param precedence - deterministic cross-domain precedence; larger values win. + * @returns a function that publishes one interaction and its teardown delegation. + */ + registerPendingInteraction( + precedence: (interaction: T) => number, + ): PendingInteractionPublisher { + const domain = new PendingInteractionDomain(precedence, () => { + this.publishPendingInteractions() + }) + const runtimeDomain = domain as unknown as RuntimePendingDomain + this.ctx.effect(() => { + this.pendingDomains.push(runtimeDomain) + this.publishPendingInteractions() + return async () => { + const delegates = domain.release() + const index = this.pendingDomains.indexOf(runtimeDomain) + this.pendingDomains.splice(index, 1) + this.publishPendingInteractions() + await Promise.allSettled(delegates.map(delegate => Promise.resolve().then(delegate))) + } + }, 'uiSession.registerPendingInteraction()') + return (interaction, delegate) => domain.publish(interaction, delegate) + } + + private rebuildBindings(): void { + const absent = this.materializeAbsent() + const bindings = new Map() + try { + for (const [sessionId, cached] of this.bindings) { + bindings.set(sessionId, this.createMaterializedBinding(cached.owner)) + } + } catch (error) { + for (const record of bindings.values()) record.release() + throw error + } + const previous = this.bindings + this.absent = absent + this.bindings = bindings + for (const record of previous.values()) record.release() + this.publishCurrent() + } + + private resolve(key: SessionId): ScopedStandardSourceBinding | undefined { + const owner = this.sessions.binding(key) + if (owner === undefined) return undefined + const cached = this.bindings.get(key) + if (cached?.owner === owner) return cached.value + const record = this.createMaterializedBinding(owner) + this.bindings.set(key, record) + cached?.release() + return record.value + } + + private resolveCurrent(): StandardSourceBinding { + const current = this.sessions.list.getSnapshot().current + return current === undefined ? this.absent : this.resolve(current) ?? this.absent + } + + private publishCurrent(): void { + const next = this.resolveCurrent() + if (next === this.currentBinding) return + this.currentBinding = next + notifySubscribers(this.currentListeners, '[ui-session] current binding') + } + + private publishPendingInteractions(): void { + const next = new Map() + for (const domain of this.pendingDomains) { + for (const interaction of domain.valuesSnapshot()) { + const precedence = domain.precedence(interaction) + const previous = next.get(interaction.sessionId) + if (previous === undefined || precedence >= previous.precedence) { + next.set(interaction.sessionId, { interaction, precedence }) + } + } + } + const projected = new Map( + [...next].map(([sessionId, value]) => [sessionId, value.interaction] as const), + ) + if (samePendingInteractions(this.pendingSnapshot, projected)) return + this.pendingSnapshot = projected + notifySubscribers(this.pendingListeners, '[ui-session] pending interactions') + } + + private createMaterializedBinding(owner: SessionBinding): MaterializedBinding { + const value = this.materialize(owner) + const releaseEffect = owner.ctx.effect(() => () => { + if (this.bindings.get(owner.sessionId) !== record) return + this.bindings.delete(owner.sessionId) + if (this.currentBinding !== value) return + this.currentBinding = this.absent + notifySubscribers(this.currentListeners, '[ui-session] current binding') + }, `ui-session: binding ${owner.sessionId}`) + const record: MaterializedBinding = { + owner, + value, + release: () => { void releaseEffect() }, + } + return record + } + + private materialize(binding: SessionBinding): ScopedStandardSourceBinding { + const hooks: Record> = {} + const keyedHooks: Record = {} + const props: Record = {} + const finalProps = new Set() + for (const descriptor of this.descriptors) { + const contribution = descriptor.resolve(binding) + validateContribution(descriptor, contribution) + copyDeclared('hook', hooks, descriptor.hooks, contribution.hooks, finalProps) + copyDeclared('keyed hook', keyedHooks, descriptor.keyedHooks, contribution.keyedHooks, finalProps) + copyDeclared('prop', props, descriptor.props, contribution.props, finalProps) + } + const value: ScopedStandardSourceBinding = { + key: binding.sessionId, + ctx: binding.ctx, + hooks, + keyedHooks, + props, + } + this.ctx.slots.bindStoreScope(value) + return value + } + + private materializeAbsent(): StandardSourceBinding { + const hooks: Record = {} + const keyedHooks: Record = {} + const props: Record = {} + const finalProps = new Set() + for (const descriptor of this.descriptors) { + declareAbsent('hook', hooks, descriptor.hooks, finalProps) + declareAbsent('keyed hook', keyedHooks, descriptor.keyedHooks, finalProps) + declareAbsent('prop', props, descriptor.props, finalProps) + } + return { key: undefined, hooks, keyedHooks, props } + } +} + +function validateContribution( + descriptor: RuntimeSessionSourceDescriptor, + contribution: RuntimeSessionSourceContribution, +): void { + rejectUndeclared('hook', descriptor.hooks, contribution.hooks) + rejectUndeclared('keyed hook', descriptor.keyedHooks, contribution.keyedHooks) + rejectUndeclared('prop', descriptor.props, contribution.props) +} + +function rejectUndeclared( + kind: string, + declared: readonly string[] | undefined, + values: Readonly> | undefined, +): void { + for (const name of Object.keys(values ?? {})) { + if (!(declared ?? []).includes(name)) { + throw new Error(`uiSession.provide: undeclared ${kind} '${name}'`) + } + } +} + +function copyDeclared( + kind: StandardMemberKind, + target: Record, + declared: readonly string[] | undefined, + values: Readonly> | undefined, + finalProps: Set, +): void { + for (const name of declared ?? []) { + claimStandardProp(kind, name, finalProps) + const value = values?.[name] + if (value === undefined) throw new Error(`uiSession.provide: missing ${kind} '${name}'`) + target[name] = value + } +} + +function declareAbsent( + kind: StandardMemberKind, + target: Record, + declared: readonly string[] | undefined, + finalProps: Set, +): void { + for (const name of declared ?? []) { + claimStandardProp(kind, name, finalProps) + target[name] = undefined + } +} + +function claimStandardProp(kind: StandardMemberKind, name: string, finalProps: Set): void { + const propName = kind === 'prop' ? name : standardHookPropName(name) + if (finalProps.has(propName)) { + throw new Error(`uiSession.provide: duplicate ${kind} '${name}' at prop '${propName}'`) + } + finalProps.add(propName) +} + +/** Required Controller and renderer services. */ +export const inject = ['sessions', 'slots'] + +/** + * Install the Session root source and scoped adapter. + * @param ctx - Client Cordis context. + */ +export function apply(ctx: Context): void { + const service = new UiSession(ctx, ctx.sessions) + ctx.slots.provideRoot({ + hooks: { + sessions: ctx.sessions.list, + sessionPendingInteraction: service.pendingInteractions, + }, + } satisfies RootStandardSourceContribution) + ctx.slots.installScope('session', service.adapter) +} + +function samePendingInteractions( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left.size !== right.size) return false + for (const [sessionId, interaction] of left) { + if (right.get(sessionId) !== interaction) return false + } + return true +} diff --git a/packages/client/ui-session/src/client/session-provider.tsx b/packages/client/ui-session/src/client/session-provider.tsx new file mode 100644 index 0000000000..6dc01aadc7 --- /dev/null +++ b/packages/client/ui-session/src/client/session-provider.tsx @@ -0,0 +1,20 @@ +/** Session-owned rendering semantics for the standard SessionProvider seat. */ +import { Fragment, type ReactNode } from 'react' +import type { + SessionAreaProps, StandardSourceBinding, +} from '@deepseek-ai/dsh-client-ui-slots' + +/** + * Render the selected Session body or its empty branch. + * @param binding - current Session scope binding. + * @param props - standard Session area render props. + * @returns the selected Session subtree, keyed by Session identity. + */ +export function renderSessionArea( + binding: StandardSourceBinding, + { empty, children }: SessionAreaProps, +): ReactNode { + const sessionId = binding.key + if (sessionId === undefined) return <>{empty?.() ?? null} + return {children} +} diff --git a/packages/client/ui-session/src/index.ts b/packages/client/ui-session/src/index.ts new file mode 100644 index 0000000000..5c12a00665 --- /dev/null +++ b/packages/client/ui-session/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser-only Session UI adapter. */ + +/** Provides no Host-side behavior. */ +export function apply(): void {} diff --git a/packages/client/ui-session/src/invariant.ts b/packages/client/ui-session/src/invariant.ts new file mode 100644 index 0000000000..21e6363265 --- /dev/null +++ b/packages/client/ui-session/src/invariant.ts @@ -0,0 +1,21 @@ +/** Package-owned invariant companion for the Session UI adapter. */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-session' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-session-invariant' +/** Service required before the companion reserves package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: the adapter materialization path enforces Session binding consistency. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts new file mode 100644 index 0000000000..97946f4c1d --- /dev/null +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -0,0 +1,557 @@ +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import type { + AgentContext, + ISessions, + SessionBinding, + SessionListState, + SessionSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { Fragment } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + apply, + type SessionPendingInteractionBase, + UiSession, +} from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' +import * as SessionInvariant from '../src/invariant.ts' + +interface SessionsBench { + readonly sessions: ISessions + readonly list: ReturnType> + readonly resolveBinding: ReturnType SessionBinding | undefined>> + readonly createSession: ReturnType> + readonly openSession: ReturnType void>> + readonly clearSession: ReturnType void>> + binding(id: SessionId): SessionBinding + select(id: SessionId | undefined): void + release(id: SessionId): Promise +} + +const sessionId = (value: string): SessionId => value as SessionId + +function createSessionsBench(_ctx: Context): SessionsBench { + const list = createSnapshotStore({ + ids: [], + byId: {}, + current: undefined, + phase: 'ready', + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) + const bindings = new Map() + const scopes = new Map() + const resolveBinding = vi.fn((id: SessionId) => bindings.get(id)) + const createSession = vi.fn(async options => + options?.sessionId ?? sessionId(`created-${String(options?.workspaceId ?? 'none')}`)) + const openSession = vi.fn((id: SessionId) => { + list.update((draft) => { draft.current = id }) + }) + const clearSession = vi.fn(() => { + list.update((draft) => { draft.current = undefined }) + }) + const sessions = { + list, + create: createSession, + open: openSession, + clear: clearSession, + binding: resolveBinding, + } as unknown as ISessions + + return { + sessions, + list, + resolveBinding, + createSession, + openSession, + clearSession, + binding(id) { + const scopeCtx = new Context() + const snapshot = createSnapshotStore({ + sessionId: id, + queue: [], + running: false, + subagent: null, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, + }) + const projections = new Map>() + const session = { + sessionId: id, + projections: { + faceOf(key: string) { + let source = projections.get(key) + if (source === undefined) { + source = createSnapshotStore(undefined) + projections.set(key, source) + } + return source + }, + }, + getSnapshot: () => snapshot.getSnapshot(), + subscribe: (listener: () => void) => snapshot.subscribe(listener), + } as unknown as SessionBinding['session'] + const binding: SessionBinding = { + sessionId: id, + session, + eventSource: new MutableSessionEventSource(), + ctx: scopeCtx as AgentContext, + } + bindings.set(id, binding) + scopes.set(id, scopeCtx) + list.update((draft) => { + if (!draft.ids.includes(id)) draft.ids.push(id) + draft.byId[id] = { + id, + displayTitle: id, + running: false, + blank: false, + updatedAt: 1, + } + }) + return binding + }, + select(id) { + list.update((draft) => { draft.current = id }) + }, + async release(id) { + bindings.delete(id) + const scopeCtx = scopes.get(id) + scopes.delete(id) + await scopeCtx?.fiber.dispose() + }, + } +} + +function createUiSession(ctx: Context, bench: SessionsBench): UiSession { + ctx.provide('slots', { bindStoreScope: vi.fn() } as never) + return new UiSession(ctx, bench.sessions) +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('UiSession bindings', () => { + it('binds each materialized Session to renderer-owned Store cleanup', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const bindStoreScope = vi.fn() + ctx.provide('slots', { bindStoreScope } as never) + const service = new UiSession(ctx, bench.sessions) + const binding = bench.binding(sessionId('s1')) + + const materialized = service.adapter.resolve(binding.sessionId) + + expect(bindStoreScope).toHaveBeenCalledOnce() + expect(bindStoreScope).toHaveBeenCalledWith(materialized) + }) + + it('materializes built-in sources, caches a binding, and publishes selection and release', async () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const id = sessionId('s1') + const binding = bench.binding(id) + const current = vi.fn() + const offCurrent = service.adapter.current.subscribe(current) + + expect(service.adapter.current.getSnapshot()).toEqual({ + key: undefined, + hooks: { session: undefined }, + keyedHooks: { projection: undefined }, + props: { sessionId: undefined }, + }) + expect(service.adapter.resolve('missing')).toBeUndefined() + + const first = service.adapter.resolve(id)! + expect(service.adapter.resolve(id)).toBe(first) + expect(first.key).toBe(id) + expect(first.hooks.session).toBe(binding.session) + expect(first.props.sessionId).toBe(id) + expect(first.keyedHooks.projection?.('status')) + .toBe(binding.session.projections.faceOf('status')) + + bench.select(id) + expect(current).toHaveBeenCalledTimes(1) + expect(service.adapter.current.getSnapshot()).toBe(first) + bench.select(id) + expect(current).toHaveBeenCalledTimes(1) + + bench.resolveBinding.mockClear() + await bench.release(id) + expect(bench.resolveBinding).not.toHaveBeenCalled() + expect(current).toHaveBeenCalledTimes(2) + expect(service.adapter.current.getSnapshot().key).toBeUndefined() + + const other = sessionId('s2') + bench.binding(other) + service.adapter.resolve(other) + bench.resolveBinding.mockClear() + await bench.release(other) + expect(bench.resolveBinding).not.toHaveBeenCalled() + expect(current).toHaveBeenCalledTimes(2) + + offCurrent() + await ctx.fiber.dispose() + }) + + it('renders the empty area and a Session-keyed selected area', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const empty = vi.fn(() => 'empty') + const children = 'session body' + if (service.adapter.renderArea === undefined) throw new Error('Session area renderer was not installed') + + const emptyArea = service.adapter.renderArea( + service.adapter.current.getSnapshot(), + { empty, children }, + ) + expect(emptyArea).toMatchObject({ + type: Fragment, + key: null, + props: { children: 'empty' }, + }) + expect(empty).toHaveBeenCalledOnce() + + const defaultEmptyArea = service.adapter.renderArea( + service.adapter.current.getSnapshot(), + { children }, + ) + expect(defaultEmptyArea).toMatchObject({ + type: Fragment, + key: null, + props: { children: null }, + }) + + const id = sessionId('s1') + bench.binding(id) + bench.select(id) + const selectedArea = service.adapter.renderArea( + service.adapter.current.getSnapshot(), + { empty, children }, + ) + expect(selectedArea).toMatchObject({ + type: Fragment, + key: id, + props: { children }, + }) + expect(empty).toHaveBeenCalledOnce() + }) + + it('contains a failing current-binding subscriber and continues dispatch', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const id = sessionId('s1') + bench.binding(id) + const failure = new Error('subscriber failed') + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + service.adapter.current.subscribe(() => { throw failure }) + const after = vi.fn() + service.adapter.current.subscribe(after) + + bench.select(id) + + expect(after).toHaveBeenCalledOnce() + expect(report).toHaveBeenCalledWith( + '[ui-session] current binding subscriber failed:', + failure, + ) + }) + + it('releases cached bindings when the owning Client context stops', async () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const id = sessionId('s1') + bench.binding(id) + bench.select(id) + service.adapter.current.getSnapshot() + + await expect(ctx.fiber.dispose()).resolves.toBeUndefined() + await bench.release(id) + }) + + it('rebuilds live bindings and removes only the disposed source contribution', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const id = sessionId('s1') + bench.binding(id) + bench.select(id) + const custom = createSnapshotStore({ value: 1 }) + const keyed = (key: string): HostObservable => createSnapshotStore(key) + + const dispose = service.provide({ + hooks: ['custom'], + keyedHooks: ['customKeyed'], + props: ['customProp'], + resolve: () => ({ + hooks: { custom }, + keyedHooks: { customKeyed: keyed }, + props: { customProp: 'value' }, + }), + }) + const disposeNeighbor = service.provide({ + props: ['neighborProp'], + resolve: () => ({ props: { neighborProp: 'neighbor' } }), + }) + + const contributed = service.adapter.current.getSnapshot() + expect(contributed.hooks.custom).toBe(custom) + expect(contributed.keyedHooks.customKeyed).toBe(keyed) + expect(contributed.props.customProp).toBe('value') + expect(contributed.props.neighborProp).toBe('neighbor') + + dispose() + const restored = service.adapter.current.getSnapshot() + expect(restored.hooks.session).toBeDefined() + expect(typeof restored.keyedHooks.projection).toBe('function') + expect(restored.props.sessionId).toBe(id) + expect(restored.hooks).not.toHaveProperty('custom') + expect(restored.props.neighborProp).toBe('neighbor') + dispose() + expect(service.adapter.current.getSnapshot().props.neighborProp).toBe('neighbor') + + disposeNeighbor() + expect(service.adapter.current.getSnapshot().props).not.toHaveProperty('neighborProp') + }) + + it.each([ + ['hook', { resolve: () => ({ hooks: { surprise: createSnapshotStore(1) } }) }], + ['keyed hook', { resolve: () => ({ keyedHooks: { surprise: () => createSnapshotStore(1) } }) }], + ['prop', { resolve: () => ({ props: { surprise: 1 } }) }], + ] as const)('rejects an undeclared %s returned by a contribution', (kind, descriptor) => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + service.adapter.resolve(bench.binding(sessionId('s1')).sessionId) + + expect(() => { service.provide(descriptor as never) }) + .toThrow(`uiSession.provide: undeclared ${kind} 'surprise'`) + }) + + it.each([ + ['hook', { hooks: ['missing'], resolve: () => ({}) }], + ['keyed hook', { keyedHooks: ['missing'], resolve: () => ({}) }], + ['prop', { props: ['missing'], resolve: () => ({}) }], + ] as const)('rejects a missing declared %s', (kind, descriptor) => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + service.adapter.resolve(bench.binding(sessionId('s1')).sessionId) + + expect(() => { service.provide(descriptor) }) + .toThrow(`uiSession.provide: missing ${kind} 'missing'`) + }) + + it.each([ + ['hook', { hooks: ['session'], resolve: () => ({ hooks: { session: createSnapshotStore(1) } }) }], + ['keyed hook', { + keyedHooks: ['projection'], + resolve: () => ({ keyedHooks: { projection: () => createSnapshotStore(1) } }), + }], + ['prop', { props: ['sessionId'], resolve: () => ({ props: { sessionId: 'other' } }) }], + ] as const)('rejects a duplicate declared %s', (kind, descriptor) => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + + expect(() => { service.provide(descriptor) }) + .toThrow(`uiSession.provide: duplicate ${kind}`) + }) + + it('rejects cross-compartment collisions at the final standard prop name', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const source = createSnapshotStore(1) + service.provide({ + hooks: ['feature'], + resolve: () => ({ hooks: { feature: source } }), + }) + const before = service.adapter.current.getSnapshot() + + expect(() => service.provide({ + keyedHooks: ['feature'], + resolve: () => ({ keyedHooks: { feature: () => source } }), + })).toThrow("uiSession.provide: duplicate keyed hook 'feature' at prop 'useFeature'") + expect(() => service.provide({ + props: ['useFeature'], + resolve: () => ({ props: { useFeature: true } }), + })).toThrow("uiSession.provide: duplicate prop 'useFeature' at prop 'useFeature'") + expect(service.adapter.current.getSnapshot()).toBe(before) + }) + + it('releases partially rebuilt bindings when a later Session contribution fails', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + service.adapter.resolve(bench.binding(sessionId('s1')).sessionId) + service.adapter.resolve(bench.binding(sessionId('s2')).sessionId) + let calls = 0 + + expect(() => service.provide({ + props: ['partial'], + resolve: () => { + calls += 1 + if (calls === 2) throw new Error('second binding failed') + return { props: { partial: true } } + }, + })).toThrow('second binding failed') + expect(calls).toBe(2) + expect(service.adapter.resolve(sessionId('s1'))?.props).not.toHaveProperty('partial') + }) +}) + +describe('UiSession pending interactions', () => { + it('publishes the highest-precedence exact object and removes each source independently', async () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const id = sessionId('s1') + const listener = vi.fn() + const off = service.pendingInteractions.subscribe(listener) + const registerApproval = service.registerPendingInteraction( + () => 0, + ) + const registerQuestion = service.registerPendingInteraction( + interaction => interaction.kind === 'plan-review' ? 2 : 1, + ) + const registerBackground = service.registerPendingInteraction( + () => -1, + ) + listener.mockClear() + + const approval = { key: 'approval:1', kind: 'approval', sessionId: id } + const duplicate = { key: 'approval:2', kind: 'approval', sessionId: id } + const question = { key: 'question:1', kind: 'question', sessionId: id } + const plan = { key: 'question:2', kind: 'plan-review', sessionId: id } + const background = { key: 'background:1', kind: 'background', sessionId: id } + const delegate = (): Promise => Promise.resolve() + const removeApproval = registerApproval(approval, delegate) + expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval) + const removeDuplicate = registerApproval(duplicate, delegate) + expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate) + const removeQuestion = registerQuestion(question, delegate) + expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question) + const removePlan = registerQuestion(plan, delegate) + expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan) + const removeBackground = registerBackground(background, delegate) + expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan) + + removeBackground() + removeQuestion() + expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan) + removePlan() + expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate) + removeDuplicate() + expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval) + removeApproval() + removeApproval() + expect(service.pendingInteractions.getSnapshot().has(id)).toBe(false) + off() + await ctx.fiber.dispose() + }) + + it('rejects duplicate keys and contains a failing aggregate subscriber', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const registerPendingInteraction = service.registerPendingInteraction( + () => 1, + ) + const interaction = { key: 'question:1', kind: 'question', sessionId: sessionId('s1') } + const delegate = () => Promise.resolve() + const remove = registerPendingInteraction(interaction, delegate) + expect(() => { registerPendingInteraction(interaction, delegate) }) + .toThrow("ui-session: duplicate pending interaction key 'question:1'") + + const failure = new Error('pending subscriber failed') + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + service.pendingInteractions.subscribe(() => { throw failure }) + const after = vi.fn() + service.pendingInteractions.subscribe(after) + + remove() + + expect(after).toHaveBeenCalledOnce() + expect(report).toHaveBeenCalledWith( + '[ui-session] pending interactions subscriber failed:', + failure, + ) + }) + + it('removes active values before awaiting their teardown delegation', async () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const service = createUiSession(ctx, bench) + const gate = Promise.withResolvers() + const delegate = vi.fn(() => gate.promise) + const publish = service.registerPendingInteraction(() => 1) + const remove = publish( + { key: 'question:1', kind: 'question', sessionId: sessionId('s1') }, + delegate, + ) + + let disposed = false + const disposal = ctx.fiber.dispose().then(() => { disposed = true }) + await vi.waitFor(() => { expect(delegate).toHaveBeenCalledOnce() }) + expect(service.pendingInteractions.getSnapshot()).toEqual(new Map()) + expect(disposed).toBe(false) + remove() + remove() + + gate.resolve(undefined) + await disposal + expect(disposed).toBe(true) + }) +}) + +describe('ui-session apply', () => { + it('provides the root sources and installs the Session scope adapter', () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + const slots = { + provideRoot: vi.fn(), + installScope: vi.fn(), + } + ctx.provide('sessions', bench.sessions) + ctx.provide('slots', slots as never) + + apply(ctx) + + expect(ctx.uiSession).toBeInstanceOf(UiSession) + expect(slots.provideRoot).toHaveBeenCalledWith({ + hooks: { + sessions: bench.sessions.list, + sessionPendingInteraction: ctx.uiSession.pendingInteractions, + }, + }) + expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter) + }) + + it('keeps the Host loader half inert and registers the invariant companion', async () => { + expect(() => { nodeApply() }).not.toThrow() + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + + await expect(ctx.plugin(SessionInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-session/tsconfig.json b/packages/client/ui-session/tsconfig.json new file mode 100644 index 0000000000..17caaf72e7 --- /dev/null +++ b/packages/client/ui-session/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../store" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/client/ui-session/tsdown.config.ts b/packages/client/ui-session/tsdown.config.ts new file mode 100644 index 0000000000..9e1e60c6ee --- /dev/null +++ b/packages/client/ui-session/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-session', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index de5fa695d8..441433fadd 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-connection", @@ -55,18 +54,19 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -76,7 +76,9 @@ "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 574bc8e2c3..d1342e9b1d 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -7,7 +7,7 @@ * Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: the settings slot declarations plus the ctx.settingsScope Context @@ -16,6 +16,8 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls ctx.locale into this program. import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow, } from './shell-contract.ts' diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index c6e2109f4d..b545ec66f3 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -1,7 +1,7 @@ /** State owner for the optional local settings-document action. */ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** Browser state of the Host-owned settings document. */ diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index a0f0bb392e..c91b608509 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 9dc6043004..286a2d52c9 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -25,7 +25,10 @@ const t: TriggerContentProps['t'] = key => (en as Record)[key] ? // Global standard kit stubs: none of these components consume the hooks. const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never -const kit = { useSessions: unusedHook, useWorkspaces: unusedHook } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention) +const kit = { useSessions: unusedHook, useSessionPendingInteraction, useWorkspaces: unusedHook } describe('chrome content', () => { it('TriggerContent renders the icon with the label in the wide column', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index c9ec593974..2ef00fc585 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -18,6 +18,10 @@ const SEAT_CONTENT: Record = { 'settings.close': 'Close', } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: SettingsRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) + function mount({ wide = true, onboardingActive = true, @@ -51,6 +55,7 @@ function mount({ const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never const props: SettingsRootComponentProps = { useSessions, + useSessionPendingInteraction, useWorkspaces: unusedHook, wide, useOnboardingSteps: select => select(steps), diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 2b50a33596..1c5b447c62 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -1,7 +1,7 @@ /** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '../src/client/index.ts' import type { SettingsRootInjected } from '../src/client/shell-contract.ts' diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index cbb773c5dc..e13a2f4ff6 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -18,7 +18,13 @@ "path": "../ui-primitives" }, { - "path": "../runtime" + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../store" }, { "path": "../ui-settings" diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 6b7f46dfe8..5366374f39 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-api-remotes" @@ -49,16 +48,16 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^" + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -66,7 +65,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 9e6cbb4c2a..c8fe63c368 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -9,7 +9,7 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' import { onboardingReadiness } from './store.ts' diff --git a/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx index 2a10873b9e..3442ab508a 100644 --- a/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-models/src/client/WelcomeNotice.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react' import type { ReactNode } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts' diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 90a2aed75a..e24e2774a3 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -6,12 +6,13 @@ * Export discipline: * packages/client/AGENTS.md. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (settings/credentials invalidations ride the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 5b688db919..349798acdd 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -9,8 +9,8 @@ import type { ConfigurableProviderView, CredentialView, IApiClient, SettingsNamespaceView, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SettingsSchemaOperations } from './schema-operations.ts' diff --git a/packages/client/ui-settings-models/src/client/welcome-store.ts b/packages/client/ui-settings-models/src/client/welcome-store.ts index 82b8e0a662..9fe4ff3113 100644 --- a/packages/client/ui-settings-models/src/client/welcome-store.ts +++ b/packages/client/ui-settings-models/src/client/welcome-store.ts @@ -5,8 +5,8 @@ * stays process-local here. */ -import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION, } from '../onboarding-copy.ts' diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 18182602d2..a3c8a1a8ea 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -24,9 +24,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {} const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - // The plugins inject `remote`; forwarded events reach them through the - // same `$dispatch` handoff the connection sink makes. - new TestRemote(ctx) + const remote = new TestRemote(ctx) // Without a settings face the mirror's reads fail and stay contained; the // Models join itself never fetches until a section actually loads. The real // ui-settings apply also provides the settingsSchema service. @@ -35,7 +33,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {} isLoopback, } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, remote } } function declare(slots: SlotRegistry): () => void { @@ -176,9 +174,9 @@ describe('pushed invalidations', () => { declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() // The fake wire face has no methods: a fetch attempt would throw. - b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1]) - b.ctx.remote.$dispatch('credentials/reference-updated', ['OPENAI_API_KEY']) - b.ctx.remote.$dispatch('llm/adapters-updated', []) + b.remote.emit('settings/document-updated', ['llm-pi-ai', 1]) + b.remote.emit('credentials/reference-updated', ['OPENAI_API_KEY']) + b.remote.emit('llm/adapters-updated', []) b.ctx.emit('connection/reset') }) @@ -210,7 +208,7 @@ describe('pushed invalidations', () => { )() injected.controller.store.update((state) => { state.status = 'ready' }) const load = vi.spyOn(injected.controller, 'load').mockResolvedValue() - b.ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) + b.remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) expect(load).toHaveBeenCalledTimes(1) }) @@ -252,7 +250,7 @@ describe('pushed invalidations', () => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) }) acknowledgement.current = WELCOME_NOTICE_VERSION - b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 1]) + b.remote.emit('settings/document-updated', ['ui-onboarding', 1]) await vi.waitFor(() => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) }) @@ -295,7 +293,7 @@ describe('pushed invalidations', () => { expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1) revision = 2 - b.ctx.remote.$dispatch('settings/document-updated', ['llm-test', revision]) + b.remote.emit('settings/document-updated', ['llm-test', revision]) await vi.waitFor(() => { expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2) diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index d8468f2270..e849904345 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -41,6 +41,10 @@ const DeepSeekConfig = Schema.object({ })), }) +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: DeepSeekOnboardingDialogProps['useSessionPendingInteraction'] = selector => selector(noAttention) + function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView { const value = apiKeyEnv === null ? {} : { apiKeyEnv } return { @@ -135,6 +139,7 @@ function harness(options: { complete, openSection, useSessions: unusedHook, + useSessionPendingInteraction, useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index bf2674976a..244bdcf78a 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -41,6 +41,10 @@ function welcomeView(value: unknown, revision = 0) { } } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: WelcomeNoticeProps['useSessionPendingInteraction'] = selector => selector(noAttention) + function mount( version?: string, mutateImpl: () => Promise = () => @@ -77,6 +81,7 @@ function mount( complete, openSection: vi.fn(), useSessions: unusedHook, + useSessionPendingInteraction, useWorkspaces: unusedHook, controller, useWelcome: bindSnapshotSelector(controller.store), diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index 52c590e2ee..dcabd42c03 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -15,11 +15,14 @@ "path": "../ui-slots" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, { "path": "../ui-settings" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index c67689bcad..270e353444 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -33,7 +33,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-remotes", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale" ], @@ -48,15 +47,14 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -66,7 +64,8 @@ "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-plugin-inventory/src/client/index.ts b/packages/client/ui-settings-plugin-inventory/src/client/index.ts index ccfd8bda5a..6cf1813536 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/index.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/index.ts @@ -1,8 +1,9 @@ /** Read-only Host plugin inventory registered into Web Settings. */ import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { PluginInventorySettingsTab, type PluginInventorySettingsTabInjected } from './PluginInventorySettingsTab.tsx' import { en, zh, type PluginInventoryLocaleKey } from './locales.ts' diff --git a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx index b955db8a5b..f54ee5fb6c 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx @@ -3,7 +3,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, NS } from '../src/client/index.ts' diff --git a/packages/client/ui-settings-plugin-inventory/tsconfig.json b/packages/client/ui-settings-plugin-inventory/tsconfig.json index dc07f1b2f8..3127e99d89 100644 --- a/packages/client/ui-settings-plugin-inventory/tsconfig.json +++ b/packages/client/ui-settings-plugin-inventory/tsconfig.json @@ -17,15 +17,15 @@ { "path": "../locale" }, - { - "path": "../runtime" - }, { "path": "../ui-settings" }, { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 5c7cf9ceeb..7edd06ff4e 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -34,7 +34,6 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" ], @@ -51,23 +50,24 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts b/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts index c09b78c057..d37d59b394 100644 --- a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts @@ -1,6 +1,7 @@ /** The agent-loop card's staged form over the `agent-loop` settings namespace. */ -import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' /** diff --git a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts b/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts index e2cde532df..3ad8c6fefe 100644 --- a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts @@ -1,6 +1,7 @@ /** The shell card's staged form over the `bash` settings namespace. */ -import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' /** diff --git a/packages/client/ui-settings-plugins/src/client/card-form.ts b/packages/client/ui-settings-plugins/src/client/card-form.ts index 255450bdb9..365a2e8fd8 100644 --- a/packages/client/ui-settings-plugins/src/client/card-form.ts +++ b/packages/client/ui-settings-plugins/src/client/card-form.ts @@ -13,8 +13,8 @@ * override equal to the composition default is still an override. */ -import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' /** The write one field's staged text performs when the card is saved. */ export type FieldWrite = diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index b7f31d6cce..aababb7d89 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -16,7 +16,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' // and the ctx.settingsScope Context merge. Cross-plugin collaboration goes // through the service, never a value import (client bundle purity gate). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: the ctx.remote Context merge and the forwarded-event key face. import type {} from '@deepseek-ai/dsh-api-remotes/client' diff --git a/packages/client/ui-settings-plugins/src/client/tab-store.ts b/packages/client/ui-settings-plugins/src/client/tab-store.ts index 5cd26c69fc..00f0b29b9d 100644 --- a/packages/client/ui-settings-plugins/src/client/tab-store.ts +++ b/packages/client/ui-settings-plugins/src/client/tab-store.ts @@ -12,7 +12,7 @@ import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' /** What the section renders. */ export interface ConfigurablePluginsTabState { diff --git a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts index 53d6428a97..e329359230 100644 --- a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts @@ -10,7 +10,8 @@ */ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' -import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { CardForm, numberField, textField, type CardActions, type CardFieldState, type CardShell, diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 539096cfcc..b1ecdfa132 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -42,10 +42,7 @@ async function bench(served?: string[]) { }, }, })) - // The section binds its scopes through the Settings surface's service, and - // forwarded Host events reach it through the same `$dispatch` handoff the - // connection sink makes. - new TestRemote(ctx) + const remote = new TestRemote(ctx) ctx.provide('connection', { isLoopback: true, api: { @@ -54,7 +51,7 @@ async function bench(served?: string[]) { }, } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings } + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } } function declareRoot(slots: SlotRegistry): () => void { @@ -148,13 +145,13 @@ describe('ui-settings-plugins apply', () => { // Which namespaces the Host serves is a registration fact the wire never // announces on its own, so the tab rides the invalidation that can // accompany a changed composition. - const { ctx, slots, describeSettings } = await bench(['bash']) + const { ctx, slots, describeSettings, remote } = await bench(['bash']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) describeSettings.mockClear() - ctx.remote.$dispatch('settings/document-updated', ['bash', 1]) + remote.emit('settings/document-updated', ['bash', 1]) await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) }) @@ -172,7 +169,7 @@ describe('ui-settings-plugins apply', () => { }) it('re-reads the credential when the Host reports the watched reference changed', async () => { - const { ctx, slots, describeCredentials } = await bench() + const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) @@ -180,19 +177,19 @@ describe('ui-settings-plugins apply', () => { // A key written on another surface changes no settings section, so this // event is the only thing that reaches the card. - ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) + remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) it('ignores a credential change for a reference no card watches', async () => { - const { ctx, slots, describeCredentials } = await bench() + const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) describeCredentials.mockClear() - ctx.remote.$dispatch('credentials/reference-updated', ['SOME_OTHER_KEY']) + remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY']) await Promise.resolve() expect(describeCredentials).not.toHaveBeenCalled() diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 9e9bced1cb..ad666e9093 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx' import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' import { BashCard } from '../src/client/BashCard.tsx' diff --git a/packages/client/ui-settings-plugins/tsconfig.json b/packages/client/ui-settings-plugins/tsconfig.json index 66472d6745..76fdfcc4f2 100644 --- a/packages/client/ui-settings-plugins/tsconfig.json +++ b/packages/client/ui-settings-plugins/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../../test-support/client-runtime" @@ -23,6 +23,9 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, { "path": "../ui-settings" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index e37334c509..94d9e4c6d7 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -33,7 +33,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-api-remotes" ], "platform": "web" @@ -51,14 +50,13 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 5413f8cb29..0ae62f4cb1 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -9,6 +9,7 @@ * ui-settings-general too. */ +import type {} from '@deepseek-ai/dsh-client-ui-slots' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index f2763da9f7..66eca29c22 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -11,8 +11,10 @@ * ui-sidebar would close a reference cycle through ui-layout and ui-theme. * Export discipline: packages/client/AGENTS.md. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +// Type-only service merge for the connection lifecycle event. +import type {} from '@deepseek-ai/dsh-client-connection/client' // Type-only pair supplying `$on` and its key face without dragging a build // artifact into the Host graph (rationale beside the same pair in // settings-scope.ts). @@ -27,6 +29,7 @@ export type { SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' export type { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts' +export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts' export type { SettingsSchemaService } from './schema.ts' export type { SchemaNode } from './schema.ts' export type { SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot } from './settings-mirror.ts' @@ -46,7 +49,7 @@ export const inject = ['connection', 'remote'] * bound to each consuming plugin's context. * @param ctx - client root context. */ -export function apply(ctx: ClientContext): void { +export function apply(ctx: Context): void { const schema = new SettingsSchemaService(ctx) const connection = ctx.get('connection') as ConnectionHandle const mirror = new SettingsDescribeMirror( @@ -55,7 +58,7 @@ export function apply(ctx: ClientContext): void { ) ctx.effect(() => { const disposers = [ - (ctx.get('remote') as ClientContext['remote']).$on('settings/document-updated', () => { void mirror.load() }), + ctx.remote.$on('settings/document-updated', () => { void mirror.load() }), ctx.on('connection/reset', () => { void mirror.load() }), ] // The first connection also emits connection/reset, so startup normally diff --git a/packages/client/runtime/src/client/contract/settings-scope.ts b/packages/client/ui-settings/src/client/settings-contract.ts similarity index 88% rename from packages/client/runtime/src/client/contract/settings-scope.ts rename to packages/client/ui-settings/src/client/settings-contract.ts index cb22cb15ed..38f95177a7 100644 --- a/packages/client/runtime/src/client/contract/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-contract.ts @@ -1,10 +1,5 @@ /** - * The settings-namespace scope contract. The type lives here, in the common - * dependency of every feature that owns a preference, while the implementation - * and its Host transport live with the Settings surface - * (`dsh-client-ui-settings`): a feature service accepts a scope through - * `attachSettings` without depending on the surface that binds it, which would - * otherwise close a reference cycle. + * Settings-namespace scope contracts owned beside the settings transport. */ /** Client-side sync state of one settings namespace. */ diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index 8e570b6512..81b190e034 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -10,7 +10,7 @@ */ import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' type SettingsFace = Pick diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 36e674c937..a49b8ba707 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -1,9 +1,7 @@ /** - * Host transport for the settings-namespace scope contract. The contract types - * live in `dsh-client-runtime` (the common dependency of every feature that - * owns a preference); this file owns the per-namespace derivation over the - * shared {@link SettingsDescribeMirror} and the serialized write path, both of - * which are Settings-surface concerns. Reads never touch the wire here: the + * Host transport for the settings-namespace scope contract. This file owns the + * per-namespace derivation over the shared {@link SettingsDescribeMirror} and + * the serialized write path. Reads never touch the wire here: the * mirror is the one `settings.describe` reader, and every scope is a selector * over its snapshot. */ @@ -13,10 +11,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' -import { - createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, - type SettingsScopeSpec, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' // Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this // package is reachable from the Host build graph through its feature-package // callers, and api-remotes' Client face imports a Host-tsdown-generated @@ -34,6 +29,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' import type { SettingsSchemaService } from './schema.ts' +import type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts' import { SettingsDescribeMirror, type SettingsDescribeFace } from './settings-mirror.ts' type SettingsFace = Pick diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index 2dbd28630f..ebd385b78d 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -15,8 +15,8 @@ function bench() { api: { settings: { describe: describeCall } }, isLoopback: true, } as never) - new TestRemote(ctx) - return { ctx, describeCall, fiber: ctx.plugin({ inject: [...inject], apply }) } + const remote = new TestRemote(ctx) + return { ctx, describeCall, remote, fiber: ctx.plugin({ inject: [...inject], apply }) } } describe('settings domain base plugin', () => { @@ -29,23 +29,23 @@ describe('settings domain base plugin', () => { }) it('refreshes the mirror on document commits and connection resets, once each', async () => { - const { ctx, describeCall, fiber } = bench() + const { ctx, describeCall, remote, fiber } = bench() await fiber.await() await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + remote.emit('settings/document-updated', ['ui-test', 0]) await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) }) ctx.emit('connection/reset') await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) }) it('fiber disposal retires the service and its invalidation subscriptions', async () => { - const { ctx, describeCall, fiber } = bench() + const { ctx, describeCall, remote, fiber } = bench() await fiber.await() await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) await fiber.dispose() expect(ctx.get('settingsScope')).toBeUndefined() expect(ctx.get('settingsSchema')).toBeUndefined() - ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) + remote.emit('settings/document-updated', ['ui-test', 0]) ctx.emit('connection/reset') await Promise.resolve() expect(describeCall).toHaveBeenCalledTimes(1) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index ddbb6784d1..0f3933f61d 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -3,7 +3,7 @@ import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsSchemaService } from '../src/client/schema.ts' import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts' import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts' @@ -244,6 +244,7 @@ describe('SettingsScopeController', () => { }) it('keeps the write queue usable when a subscriber throws', async () => { + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) .mockResolvedValueOnce(described({ preference: 'light' }, 2)) @@ -254,12 +255,17 @@ describe('SettingsScopeController', () => { thrown = true throw new Error('subscriber failed') }) - await expect(mirror.load()).rejects.toThrow('subscriber failed') + await expect(mirror.load()).resolves.toBeUndefined() await expect(mirror.load()).resolves.toBeUndefined() expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) + expect(report).toHaveBeenCalledWith('[client-store] subscriber failed:', expect.objectContaining({ + message: 'subscriber failed', + })) + report.mockRestore() }) it('keeps the write queue usable when a write publication listener throws', async () => { + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1)) const mutate = vi.fn() .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2))) @@ -273,11 +279,38 @@ describe('SettingsScopeController', () => { throw new Error('write subscriber failed') }) - await expect(scope.set('preference', 'dark')).rejects.toThrow('write subscriber failed') + await expect(scope.set('preference', 'dark')).resolves.toBeUndefined() await expect(scope.set('preference', 'light')).resolves.toBeUndefined() expect(mutate).toHaveBeenCalledTimes(2) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 }) + expect(report).toHaveBeenCalledWith('[client-store] subscriber failed:', expect.objectContaining({ + message: 'write subscriber failed', + })) + report.mockRestore() + }) + + it('keeps the write queue usable after a failed mirror fold', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 1)) + const mutate = vi.fn() + .mockResolvedValueOnce(ok(view({ preference: 'dark' }, 2))) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + vi.spyOn(mirror, 'acceptView').mockImplementationOnce(() => { + throw new Error('mirror fold failed') + }) + + await expect(scope.set('preference', 'dark')).rejects.toThrow('mirror fold failed') + await expect(scope.set('preference', 'light')).resolves.toBeUndefined() + + expect(mutate).toHaveBeenCalledTimes(2) + expect(mutate).toHaveBeenNthCalledWith(2, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'light' }], + expectedRevision: 1, + }) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 3 }) }) it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index fa84d80082..29fcccb57a 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -15,7 +15,7 @@ "path": "../ui-slots" }, { - "path": "../runtime" + "path": "../store" }, { "path": "../../../vendor/schemastery" @@ -23,6 +23,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../../settings/settings" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index b008baa603..50ccb7ed46 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -32,8 +32,11 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-client-locale" ], "platform": "web" @@ -48,19 +51,25 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 65f0102543..4b4e2e1538 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -8,10 +8,10 @@ * actions in `sidebar.footer.action`. */ import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 5b8ac288f7..2103f133e2 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,7 +1,11 @@ /** Registers the sidebar shell into the layout-owned slot. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: pulls the Session root standard-props merge. +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' import { en, zh, type SidebarKey } from './locales.ts' @@ -22,19 +26,24 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin (shell controls copy). */ const NS = 'sidebar' +interface WorkspaceNavigation { + startSession(workspaceId?: Parameters[0]): void +} + /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] +export const inject = ['slots', 'layout', 'uiWorkspace', 'locale'] /** Registers the sidebar shell and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { + const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries') const injectProps = (): SidebarRootInjected => ({ - // The shell's New Session button rides the runtime's shared action + // The shell's New Session button rides the Workspace UI's shared action // (current Session Workspace, then recent Workspace). - startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, + startSession: (workspaceId) => { workspaceNavigation.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) ctx.effect( diff --git a/packages/client/ui-sidebar/tests/apply.client.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx index ed40b6e1b4..23c2023a8c 100644 --- a/packages/client/ui-sidebar/tests/apply.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.client.spec.tsx @@ -1,7 +1,7 @@ -/** Sidebar shell slot registration and its plain runtime/layout callbacks. */ +/** Sidebar shell slot registration and its Session/layout callbacks. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -10,11 +10,9 @@ async function bench(declare = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const layout = { toggleSidebar: vi.fn() } - const workspaces = { startSession: vi.fn() } - const sessions = { open: vi.fn(), clear: vi.fn() } + const uiWorkspace = { startSession: vi.fn() } ctx.provide('layout', layout) - ctx.provide('sessions', sessions as never) - ctx.provide('workspaces', workspaces as never) + ctx.provide('uiWorkspace', uiWorkspace as never) ctx.provide('locale', new LocaleRuntime(ctx)) const slots = ctx.get('slots') as SlotRegistry if (declare) { @@ -23,12 +21,12 @@ async function bench(declare = true) { () => null, ) } - return { ctx, slots, layout, workspaces, sessions } + return { ctx, slots, layout, uiWorkspace } } describe('ui-sidebar apply', () => { it('declares only the services it uses', () => { - expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale']) + expect(inject).toEqual(['slots', 'layout', 'uiWorkspace', 'locale']) }) it('registers the shell and declares its child seats', async () => { @@ -44,11 +42,11 @@ describe('ui-sidebar apply', () => { expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar') const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) - // Both arms delegate to the runtime's shared New Session action. + // Both arms delegate to the Workspace UI's shared New Session action. injected.startSession('workspace' as never) - expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace') + expect(b.uiWorkspace.startSession).toHaveBeenCalledWith('workspace') injected.startSession() - expect(b.workspaces.startSession).toHaveBeenLastCalledWith(undefined) + expect(b.uiWorkspace.startSession).toHaveBeenLastCalledWith(undefined) injected.toggleSidebar() expect(b.layout.toggleSidebar).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index dace9db04a..9adc7b51df 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -18,6 +18,9 @@ const COLUMN_HEIGHT = 600 const t: SidebarRootComponentProps['t'] = key => (en as Record)[key] ?? key /** The shell never reads the global hooks; the props share carries them regardless. */ const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: SidebarRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) afterEach(() => { cleanup() @@ -32,7 +35,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { const view = render(
) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 99ae7e4d5e..69a395d61c 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -22,6 +22,9 @@ afterEach(() => { // The shell never reads the global hooks itself, but they ride the standard // props share; stub them as never-called functions. const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: SidebarRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) { const startSession = vi.fn() @@ -35,7 +38,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w const root = () => ( { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456') const { container } = render( options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx index dccea1bfc2..b2d10c43d9 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx @@ -33,10 +33,11 @@ afterEach(() => { */ async function bench(options: { locale?: 'en' } = {}) { const runtime = await SlotTestRuntime.create() - runtime.provide('layout', { toggleSidebar: vi.fn() }) + runtime.ctx.provide('layout', { toggleSidebar: vi.fn() }) + runtime.ctx.provide('uiWorkspace', { startSession: vi.fn() } as never) const locale = new LocaleRuntime(runtime.ctx) if (options.locale === 'en') locale.setLocale('en') - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } }) await runtime.mount({ inject: [...inject], apply }) diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index 8caf638424..2bffe00661 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/workspace-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -18,7 +21,10 @@ "path": "../ui-primitives" }, { - "path": "../runtime" + "path": "../ui-renderer" + }, + { + "path": "../ui-session" }, { "path": "../ui-layout" diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index e04d03aa88..c744093af2 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -31,9 +31,13 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-tool", "@deepseek-ai/dsh-client-ui-input-trigger", "@deepseek-ai/dsh-api-remotes" @@ -48,25 +52,29 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 738034e4e8..c62df1051f 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -30,11 +30,15 @@ * accent row derived only from each logged call/result slice. */ // Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. -import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' -import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ConnectionHandle, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' +import { resolveClientSessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { SkillRow } from './SkillRow.tsx' import { en, NS, zh, type SkillKey } from './locales.ts' @@ -68,7 +72,7 @@ export function apply(ctx: ClientContext): void { )) const skills = (ctx.get('connection') as ConnectionHandle).api.skills - const sessions = ctx.get('sessions') as ISessions + const sessions = resolveClientSessions(ctx) // Session-keyed catalog cache; single-flight per key. Plugin-closure state: // the fiber effect below is its teardown boundary. const fetches = new Map() diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 2f8da20713..293c3d2168 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -15,8 +15,8 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientSessionContext, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -74,10 +74,10 @@ async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) - new TestRemote(ctx) + const remote = new TestRemote(ctx) providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() - return { ctx, source: captured! } + return { ctx, source: captured!, remote } } const CATALOG: SkillRow[] = [ @@ -269,13 +269,13 @@ describe('catalog cache', () => { it('agent-preset/selected clears only the recomposed session', async () => { const { list, payloads } = countingList() - const { ctx, source } = await bench(list) + const { source, remote } = await bench(list) await source.candidates(proj('s1'), req('')) await source.candidates(proj('s2'), req('')) expect(payloads).toHaveLength(2) // The catalog a preset supplies is the preset's; the other session's // composition did not change, so its cached catalog still holds. - ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal']) + remote.emit('agent-preset/selected', [sid('s1'), 'minimal']) await source.candidates(proj('s1'), req('')) await source.candidates(proj('s2'), req('')) expect(payloads).toHaveLength(3) diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index 8fbbaef7c0..0124cb56e1 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { SkillRow } from '../src/client/SkillRow.tsx' diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index 33bf3ed459..1771d32619 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -11,6 +11,12 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, { "path": "../../../vendor/cordis" }, @@ -18,7 +24,7 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../ui-renderer" }, { "path": "../ui-tool" @@ -34,9 +40,6 @@ }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml index 2f9729cfbd..01538011c2 100644 --- a/packages/client/ui-slots/README.i18n.yaml +++ b/packages/client/ui-slots/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-slots/README.md -README.md: 332bd092e8a4dec5a424acccce57c3dd0d8c27d8 -README.zh.md: 369776ea72c6f21184c0accd183aa9b409ee867a +README.md: 7d5bffae32f4f1056f54d02f4a949bc7ed34058d +README.zh.md: 8c242b94c88aa7414a6e32641269fb1e4ce4308c diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index 332bd092e8..7d5bffae32 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -19,7 +19,7 @@ The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are The store family (`defineStore` spec in / `StoreHandle` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding belongs to the render machinery; only the props-contract hook type (`SnapshotSelectorHook`) lives here. -`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. Each key also carries a declaration epoch that advances only on declaration and collapse; the runtime uses it for [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection), independently from ordinary entry versions. `renderer.ts` carries the installation contract (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; ui-renderer owns both the implementation and its plugin-lifecycle installation. +`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. Each key also carries a declaration epoch that advances only on declaration and collapse; `ui-renderer` uses it for [`ctx.slots.inject`](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md), independently from ordinary entry versions. `renderer.ts` carries the installation contract (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; ui-renderer owns both the implementation and its plugin-lifecycle installation. ## Model Experience diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md index 369776ea72..8c242b94c8 100644 --- a/packages/client/ui-slots/README.zh.md +++ b/packages/client/ui-slots/README.zh.md @@ -19,7 +19,7 @@ chain-kind slot 会反转键控路由:条目自行提名,而不是由分发 store 家族(输入 `defineStore` 规范/输出 `StoreHandle`)为 store seat 建模:`init` 推断状态 schema;`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于运行时包(引擎所属位置),并满足这里导出的 `DefineStore` 约定。引擎产物与 renderer host 约定携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React 钩子;钩子绑定属于渲染机制,只有 props 约定钩子类型(`SnapshotSelectorHook`)位于这里。 -`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。每个 key 还携带一个 declaration epoch(声明代次),它只在声明与移除时递增;运行时将其用于 [`ctx.slots.inject`](../runtime/README.zh.md#slot-declaration-injection),且与普通条目版本相互独立。`renderer.ts` 携带安装约定(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;ui-renderer 同时持有实现及其插件生命周期安装。 +`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。每个 key 还携带一个 declaration epoch(声明代次),它只在声明与移除时递增;`ui-renderer` 将其用于 [`ctx.slots.inject`](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md),且与普通条目版本相互独立。`renderer.ts` 携带安装约定(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;ui-renderer 同时持有实现及其插件生命周期安装。 ## 模型体验 diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 2fc6e4840f..d93b9addc9 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -27,6 +27,7 @@ }, "license": "MIT", "devDependencies": { + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 9e443ab454..8579684fa6 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -177,28 +177,29 @@ export type ScopeOf = SlotMap[K]['scope'] /** * Framework standard kit delivered to every session-scope slot component. - * Declared EMPTY here (zero-dependency layer): the runtime package merges the - * real members (`useSession` bound to the conversation snapshot and the - * framework-supplied `sessionId`) exactly as consumers merge SlotMap keys. + * Declared empty here (zero-dependency layer): `ui-session` merges the + * Session lifecycle hook, projection hook, and Session identity; domain UI + * adapters merge their own standard hooks exactly as consumers merge SlotMap keys. */ export interface SessionStandardProps {} /** * Framework standard kit delivered to current-session-optional slots. Its * hooks stay callable while no session is selected and return `undefined` - * until one becomes current; concrete members merge in at runtime packages. + * until one becomes current; `ui-session` and domain UI adapters merge the + * concrete members. */ export interface SessionMaybeStandardProps {} /** * Framework standard kit delivered to EVERY slot component (the global seat). - * Declared empty here; the runtime package merges the global object-layer - * selector hooks that shared page composition consumes. + * Declared empty here; each owning UI adapter merges the global selector hooks + * that shared page composition consumes. */ export interface GlobalStandardProps {} /** - * The session id type as the runtime's SessionStandardProps merge declares it + * The session id type as `ui-session`'s SessionStandardProps merge declares it * (branded); falls back to `string` in programs without the merge (this * package's own tests). */ @@ -233,6 +234,8 @@ export interface RenderOpts { export interface ChainRenderOpts { /** The owner's fallback body, rendered when every entry's selector declines. */ fallback?: ReactNode + /** Render only the owner fallback without resolving or dispatching the chain's scope. */ + fallbackOnly?: boolean /** * Keep the fallback permanently mounted: an election hides it (wrapped, * display:none) instead of unmounting it, and the all-decline case shows it @@ -302,25 +305,18 @@ type RenderSlotFn = export type MatchedShare = E['kind'] extends 'chain' ? { matched: M } : object -/** - * Conversation-session selector hook alias for props contracts. Wide by - * default at this dependency-inverted layer; the runtime narrows at its - * export outlet (`UseSession`). - */ -export type UseSession = SnapshotSelectorHook - -/** Props of the standard-kit SessionProvider seat (render-prop form). */ +/** Props of the standard-kit SessionProvider seat. */ export interface SessionAreaProps { /** No-session body (also covers a current id whose session cannot be resolved). */ empty?: (() => ReactNode) | undefined - /** Session body; the framework remounts it per session (key=sessionId). */ - children: (sessionId: SessionIdOf) => ReactNode + /** Session body; the framework remounts it per session identity. */ + children: ReactNode } /** - * Framework-wired session area component. It subscribes to runtime-owned - * session selection and is injected into entries that declare session-scoped - * children; business code does not import it directly. + * Framework-wired session area component. `ui-session` supplies the current + * Controller binding through the renderer scope adapter; entries that declare + * session-scoped children receive this component without importing it. */ export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode diff --git a/packages/client/ui-slots/src/invariant.ts b/packages/client/ui-slots/src/invariant.ts index 9ae576095e..a46ba4e30f 100644 --- a/packages/client/ui-slots/src/invariant.ts +++ b/packages/client/ui-slots/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: a zero-dependency pure registry core — it emits no - * cordis events itself (the runtime SlotRegistry wrapper owns the event + * cordis events itself (the `ui-renderer` SlotRegistry owns the event * bridge and its invariants); define/register/dispose sequencing is asserted * directly by this package's behavior specs. */ diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index e0afea710b..c32d03b9b4 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -1,6 +1,10 @@ /** React-free contracts between the slot host and an installed renderer. */ +import type { Context } from '@deepseek-ai/cordis' import type { ReactNode } from 'react' -import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { + SessionAreaProps, SlotEntryDef, SlotScope, SlotSpec, StoredEntry, Translate, +} from './index.ts' /** * The locale face the render machinery consumes: namespace binding plus an @@ -9,7 +13,7 @@ import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts' * active-locale or registry change; the renderer re-derives each entry's `t` * from (namespace, revision), so a locale switch hands out NEW function * references and memoized components re-render naturally. Implemented by the - * locale plugin, installed through the runtime SlotRegistry (installLocale). + * locale plugin, installed through the `ui-renderer` SlotRegistry. * Install before the first render that needs the seat: outlets bind their * revision subscription at mount, and a face appearing later has no channel * to notify already-mounted outlets (the locale plugin is immediately-tier @@ -27,10 +31,16 @@ export interface LocaleFace extends HostObservable<{ revision: number }> { bind(ns: string): Translate } -/** Minimal observable API for host-provided standard-kit data sources. */ -export interface HostObservable { - getSnapshot(): T - subscribe(fn: () => void): () => void +/** Observable currency shared by domain sources, stores, and the renderer. */ +export type HostObservable = ObservableSnapshot + +/** + * Convert one standard source name to its rendered Hook prop name. + * @param name - registered fixed or keyed source name. + * @returns the `use` prop exposed to Slot components. + */ +export function standardHookPropName(name: string): string { + return `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` } /** @@ -51,41 +61,57 @@ export interface StoreInstanceLike { readonly actions: Record void> } +/** Resolve one member of an open-key standard hook family. */ +export type KeyedStandardSource = (key: string) => HostObservable | undefined + /** - * Per-session standard props resolved per session id (identity-stable per - * session scope; a recreated scope yields a new info). Plugins contribute - * members through the runtime `sessions.provide` contract; the render side binds - * every `hooks` source into a `use` selector hook (hooks never appear - * on the host contract) and spreads `props` verbatim. The runtime itself - * contributes the first entry (`'session'` → `useSession`). + * Framework-neutral inputs from which the renderer materializes standard + * props. A scope adapter keeps member names present while its binding is + * absent so optional slots retain a stable Hook call order. */ -export interface SessionMaybeProvideInfo { - /** Current session id, absent while the application is in no-session mode. */ - sessionId: string | undefined - /** - * Static hook roster. Each value is absent with the session; keys remain so - * session-maybe entries always receive the same hook-shaped standard kit. - */ - hooks: Record | undefined> - /** Static plain-member roster; values are undefined with the session. */ - props: Record - /** - * Key-addressed projection value sources (the useProjection framework seat; - * session-projection subsystem page: docs/subsystems/session-projection.md). - * Unlike `hooks`, the key space is open — values - * arrive from host-computed push frames — so the render side binds per - * resolved key instead of per static roster member. Faces are always - * defined per key (absence is an `undefined` snapshot); the whole member is - * absent with the session. - */ - projections?: { faceOf(key: string): HostObservable } | undefined +export interface StandardSourceBinding { + /** Scope identity; absent for root data and an optional scope with no selection. */ + readonly key: string | undefined + /** Fixed-name sources; each `name` becomes a `useName` selector Hook. */ + readonly hooks: Readonly | undefined>> + /** Open-key source families; each `name` becomes a `useName(key, selector)` Hook. */ + readonly keyedHooks: Readonly> + /** Stable plain values copied into standard props. */ + readonly props: Readonly> } -/** Definite per-session standard props resolved for strict session slots. */ -export interface SessionProvideInfo extends SessionMaybeProvideInfo { - sessionId: string - /** Bare observable sources, keyed by hook base name ('session' → useSession). */ - hooks: Record> +/** Materialized binding for one live non-root scope. */ +export interface ScopedStandardSourceBinding extends StandardSourceBinding { + readonly key: string + readonly ctx: Context +} + +/** One installed source of bindings for a non-root Slot scope. */ +export interface SlotScopeAdapter { + /** Binding that follows the current selection, including its absent projection. */ + readonly current: HostObservable + /** + * Resolve an already-materialized binding. + * @param key - scope identity. + * @returns the binding, or `undefined` when the identity is unavailable. + */ + resolve(key: string): ScopedStandardSourceBinding | undefined + /** + * Render the scope owner's area seat over the current binding. The renderer + * binds this function to the standard `SessionProvider` prop without owning + * Session selection semantics. + * @param binding - current scope binding, including its absent projection. + * @param props - render-prop body and empty branch. + * @returns rendered scope area. + */ + renderArea?(binding: StandardSourceBinding, props: SessionAreaProps): ReactNode +} + +/** Root standard-source contribution installed by one domain UI package. */ +export interface RootStandardSourceContribution { + readonly hooks?: Readonly>> + readonly keyedHooks?: Readonly> + readonly props?: Readonly> } /** renderSlot dispatch options at the machinery level. */ @@ -97,7 +123,7 @@ export interface RenderOpts { hookContext?: unknown } -/** Host API the runtime SlotRegistry presents to the installed renderer. */ +/** Host API the `ui-renderer` SlotRegistry presents to its React renderer. */ export interface SlotRendererHost { /** * Subscribe to a key's registration changes (microtask-batched). @@ -155,28 +181,21 @@ export interface SlotRendererHost { * Resolve (create or return cached) the store instance for an entry's * declared handle under a scope key; lifecycle rides the ledger axis. * @param entry - entry whose declaration carries the handle. - * @param scopeKey - session id for session-scope slots, undefined for root scope. + * @param scopeBinding - exact Session binding for scoped slots, undefined for root scope. * @returns the instance, or undefined when the entry declares no store. */ - storeOf(entry: StoredEntry, scopeKey: string | undefined): StoreInstanceLike | undefined - /** Session-side standard-kit sources. */ - sessions: { - /** Session list source backing the useSessions standard hook. */ - list: HostObservable - /** - * Atomic current-session provide projection used by SessionProvider: - * selection changes and provider-roster changes publish through this one - * source, so a stable current id cannot strand mounted entries on an - * obsolete hook/prop schema. Carries the static roster with sessionId - * undefined while no current session resolves. - */ - provideInfo: HostObservable - } - /** Workspace-side standard-kit sources. */ - workspaces: { - /** Workspace list source backing the useWorkspaces standard hook. */ - list: HostObservable - } + storeOf(entry: StoredEntry, scopeBinding: ScopedStandardSourceBinding | undefined): StoreInstanceLike | undefined + /** Root standard data assembled from domain-owned contributions. */ + readonly root: HostObservable + /** Monotonic source updated whenever the installed scope-adapter roster changes. */ + readonly scopeRevision: HostObservable + /** + * Resolve the adapter installed for one non-root scope. + * `session` and `session-maybe` intentionally resolve the same adapter. + * @param scope - Slot scope. + * @returns adapter, or `undefined` when the composition omitted its owner. + */ + scope(scope: Exclude): SlotScopeAdapter | undefined /** * Installed locale face backing the `t` standard seat (absent until the * locale plugin installs one; rendering an entry that declared `locale:` @@ -185,7 +204,7 @@ export interface SlotRendererHost { locale?: LocaleFace | undefined } -/** The installation contract: runtime owns install()/renderSlot(); ui-renderer implements rendering. */ +/** The installation contract between the `ui-renderer` SlotRegistry and its React renderer. */ export interface SlotRenderer { /** * Render the root slot tree over the host API (the only ctx-level entry). diff --git a/packages/client/ui-slots/src/store.ts b/packages/client/ui-slots/src/store.ts index 64e0f9b4b0..907762b7b4 100644 --- a/packages/client/ui-slots/src/store.ts +++ b/packages/client/ui-slots/src/store.ts @@ -1,132 +1,17 @@ -/** Framework-neutral store contracts for slot registrations and the runtime engine. */ +/** Slot-facing re-exports of the React-free store contracts. */ -/** - * Typed selector hook over a snapshot source. Canonical shape for the whole - * slot system (ui-renderer's engine hook is structurally identical; the - * framework is the only party that ever constructs one). - */ -export type SnapshotSelectorHook = (sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S - -/** - * Selector hook over a source that follows the current session. The hook is - * always present, while its selected value is absent whenever no session is - * current. This keeps hook call sites stable across no-session/session - * transitions without pretending that a session snapshot exists. - */ -export type MaybeSnapshotSelectorHook = - (sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S | undefined - -/** - * Action declaration table: pure immer-draft transforms over the store state, - * declared as the store's complete write set (the audit face — components can - * only write through these). - */ -/* oxlint-disable-next-line typescript/no-explicit-any -- - * any[] (not unknown[]): each action carries its own parameter list, and - * unknown[] would reject every concrete signature under strict parameter - * contravariance. Params are re-inferred per action by BakedActions. */ -export type ActionsDecl = Record void> - -/** - * Draft-stripped callback form of an actions table: what components - * (`props.actions`) and inject factories receive — the framework bakes the - * draft parameter away by binding each action to the resolved instance. - */ -export type BakedActions> = { - [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never -} - -/** - * Store declaration spec: initial-state factory (a lambda so every instance - * gets a fresh state), optional persistence key (mechanical, framework-run), - * and the actions write set. - */ -export interface StoreSpec> { - init: () => T - persist?: string - actions: A -} - -/** - * Live engine instance: the create() product consumed by the render machinery - * and by tests. A bare snapshot source plus the baked write set — no React - * hook rides the engine product (the engine lives in the React-free runtime); - * the render machinery binds the `useStore` hook from this source on its own - * side, cached per instance. Production components and render paths never - * call create() themselves — instance lifecycle is the framework's. - */ -export interface StoreInstance> { - readonly actions: BakedActions - getSnapshot(): T - /** - * Subscribe to state changes (uSES subscribe side). - * @param fn - change callback. - * @returns unsubscribe. - */ - subscribe(fn: () => void): () => void - /** - * Drop this instance's persisted value (no-op for non-persist specs). The - * framework calls it when the owning scope dies for good — a pruned session - * must not leave orphaned storage keys behind. - */ - clearPersisted(): void -} - -/** - * Store handle: spec + state/actions types + shared identity + instance - * factory in one value. Handles are constructed in apply world (shared across - * registrations of one plugin) or by the framework from a registrant's - * factory (exclusive). Never export a handle at module level — module-cache - * identity is a disguised singleton across plugin reloads. - */ -export interface StoreHandle> { - readonly spec: StoreSpec - /** - * Create a live engine instance (framework machinery and tests only). - * @param scopeKey - session id for session-scope instances; suffixes the - * persist key so per-session instances persist independently (root-scope - * instances omit it). - * @returns a fresh instance seeded from `spec.init()`. - */ - create(scopeKey?: string): StoreInstance -} - -/** - * Exclusive-store registration form: the registrant passes the factory itself - * and the framework calls it per entry x scope (no shared identity exists). - */ -/* oxlint-disable-next-line typescript/no-explicit-any -- - * erased position accepting every StoreHandle instantiation; T/A are - * recovered per use site by conditional inference (HandleOf/BoundActions/ - * PropsStore). */ -export type StoreFactory = () => StoreHandle - -/** The register `store` option position: a shared handle or an exclusive factory. */ -// oxlint-disable-next-line typescript/no-explicit-any -- same erased-constraint position as StoreFactory (see above). -export type StoreDecl = StoreHandle | StoreFactory - -/** Normalize a store declaration to its handle type (factories yield their return). */ -export type HandleOf = H extends () => infer R ? R : H - -/** - * Handle-keyed baked actions: the `actions` parameter of an inject factory - * whose registration declared a store — the same baked callback set the - * component receives via {@link PropsStore}. - */ -export type BoundActions = H extends StoreHandle ? BakedActions : never - -/** - * The store props share, derived from the declared handle: a typed selector - * hook plus the baked write set. Components never see the instance itself - * (no update/set — reads via useStore, writes via the declared actions only). - */ -export type PropsStore = H extends StoreHandle - ? { useStore: SnapshotSelectorHook; actions: BakedActions } - : object - -/** - * The defineStore contract (implementation lives in the runtime package, - * bound to the snapshot-store engine): spec in, handle out, with T inferred - * from `init` and the actions table constrained by T. - */ -export type DefineStore = >(spec: StoreSpec) => StoreHandle +export type { + ActionsDecl, + BakedActions, + BoundActions, + DefineStore, + HandleOf, + MaybeSnapshotSelectorHook, + PropsStore, + SnapshotSelectorHook, + StoreDecl, + StoreFactory, + StoreHandle, + StoreInstance, + StoreSpec, +} from '@deepseek-ai/dsh-client-store' diff --git a/packages/client/ui-slots/tests/core.client.spec.ts b/packages/client/ui-slots/tests/core.client.spec.ts index 69e283ba66..41e0d1b843 100644 --- a/packages/client/ui-slots/tests/core.client.spec.ts +++ b/packages/client/ui-slots/tests/core.client.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' -// 'root' is NOT merged here: the runtime package owns the built-in row, and +// 'root' is NOT merged here: ui-renderer owns the built-in row, and // the client aggregate program would see both merges collide. declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { diff --git a/packages/client/ui-slots/tests/type-chain.client.spec.tsx b/packages/client/ui-slots/tests/type-chain.client.spec.tsx index e0c4c36744..b74bdcc775 100644 --- a/packages/client/ui-slots/tests/type-chain.client.spec.tsx +++ b/packages/client/ui-slots/tests/type-chain.client.spec.tsx @@ -11,7 +11,7 @@ import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' // Only package-unique SlotMap keys are merged here. The standard-kit // interfaces (SessionStandardProps/GlobalStandardProps) are NOT re-merged: -// the runtime package owns the real members, and in the client aggregate +// the owning UI adapters provide the real members, and in the client aggregate // program a toy merge would collide with them — samples below stay // shape-agnostic about kit member payloads for the same reason. declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -137,8 +137,8 @@ describe('terminal-design type chain', () => { core.register({ name: 'chain.conv', store: chat }, Details) // Owner + store shares arrive typed on the component face. Standard-kit - // member payloads are the runtime merge's property — not probed here - // (the runtime package's own tests cover them). + // member payloads are the owning adapters' property — not probed here + // (their package tests cover them). fp.renderSlot('chain.side', { collapsed: false, width: 280 }) const draft: string = cp.useStore(s => s.draft) cp.actions.select({ id: 'm1' }) @@ -283,7 +283,7 @@ describe('terminal-design type chain', () => { acts.setDraft(1) // SessionProvider seat: derives from a session-scope child declaration. - fp.SessionProvider({ empty: () => null, children: () => null }) + fp.SessionProvider({ empty: () => null, children: null }) const sideOnly: PropsRenderSlots<'chain.side'> = null as never // @ts-expect-error only root-scope children declared → no SessionProvider seat void sideOnly.SessionProvider diff --git a/packages/client/ui-slots/tsconfig.json b/packages/client/ui-slots/tsconfig.json index 79d7441ae3..630e7dc65f 100644 --- a/packages/client/ui-slots/tsconfig.json +++ b/packages/client/ui-slots/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../store" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 16d497e7ff..775289d61b 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -31,9 +31,12 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client" + ], "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-input-trigger" @@ -48,17 +51,20 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", @@ -71,7 +77,12 @@ "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index e82d8adc44..80c9dc714d 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -3,9 +3,11 @@ import { } from 'react' import { createPortal } from 'react-dom' import { - indexSubagentDescendants, type SessionId, type SessionListState, type SessionProjectionMap, - type SessionSummary, type SubagentAddress, type SubagentCatalogSnapshot, -} from '@deepseek-ai/dsh-client-runtime/client' + indexSubagentDescendants, type SessionListState, type SessionProjectionMap, + type SessionSummary, type SubagentCatalogSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index ab53562d5a..538eaf33f0 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -1,13 +1,15 @@ /** Web subagent catalog, navigation, and addressed-session composer owner. */ -import type { - ClientContext, SessionId, SubagentAddress, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentHeaderLineage.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { en, NS, zh, type SubagentKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index f0c9e9853c..0fc346f9ee 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -2,10 +2,12 @@ import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { describe, expect, it } from 'vitest' -import { - SlotRegistry, type ConversationSnapshot, type SessionId, type SessionListState, - type SessionSummary, type SubagentAddress, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { + SessionListState, SessionSnapshot, SessionSummary, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { @@ -115,13 +117,14 @@ describe('apply', () => { .find(entry => entry.component === SubagentReadOnlyComposer)! const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null const owner = ( - subagent: ConversationSnapshot['subagent'] | undefined, + subagent: SessionSnapshot['subagent'] | undefined, running = false, ): ComposerChainProps => ({ - interactions: [], + sessionId: subagent?.address.childSessionId, session: subagent === undefined ? undefined - : ({ subagent, running } as unknown as ConversationSnapshot), + : ({ subagent, running } as SessionSnapshot), + pendingInteraction: undefined, }) expect(select(owner(undefined))).toBeNull() expect(select(owner(null))).toBeNull() diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index d4001659bb..c507acfe1c 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -3,8 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { - SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, -} from '@deepseek-ai/dsh-client-runtime/client' + SessionListState, SessionSummary, SubagentCatalogSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SubagentHeaderLineage, type SubagentHeaderLineageProps, } from '../src/client/SubagentHeaderLineage.tsx' @@ -267,6 +268,28 @@ describe('SubagentHeaderLineage', () => { await advance(120) }) + it('repositions an open catalog after viewport resize and document scroll', () => { + const view = render() + const trigger = screen.getByRole('button', { name: /2 个子代理/ }) + const bounds = vi.spyOn(trigger, 'getBoundingClientRect') + bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect) + hoverCatalog(trigger) + const tree = screen.getByRole('tree') + expect(tree.style.top).toBe('25px') + expect(tree.style.left).toBe('30px') + + bounds.mockReturnValue({ bottom: 70, left: 80 } as DOMRect) + act(() => { window.dispatchEvent(new Event('resize')) }) + expect(tree.style.top).toBe('75px') + expect(tree.style.left).toBe('80px') + + bounds.mockReturnValue({ bottom: 90, left: 100 } as DOMRect) + act(() => { document.dispatchEvent(new Event('scroll')) }) + expect(tree.style.top).toBe('95px') + expect(tree.style.left).toBe('100px') + view.unmount() + }) + it('cancels a pending hover when the trigger becomes hidden', async () => { vi.useFakeTimers() const view = render() diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index ac34489efb..358585ecf7 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -8,27 +8,42 @@ "src" ], "references": [ + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../locale" }, - { - "path": "../runtime" - }, { "path": "../ui-conversation" }, + { + "path": "../ui-input-trigger" + }, { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, { "path": "../../llm/token-meter" }, + { + "path": "../../core/session" + }, { "path": "../../subagent/subagent" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index eed01f67ba..4bb37da9e4 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -33,8 +33,8 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" ], @@ -48,7 +48,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -59,9 +59,10 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 8d9d8ac6b1..c6522e7ffe 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -7,14 +7,15 @@ * document. The plugin also registers the Appearance preference row into the * settings General section — the theme feature owns its own settings surface. */ -import type { Context } from '@deepseek-ai/cordis' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration // goes through the service, never a value import (client bundle purity gate). -import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' @@ -149,7 +150,7 @@ const BUILTIN_INSPECT_TOKENS: readonly ThemeTokenInspection[] = Object.freeze([ * preference is `system`. */ export class ThemeRuntime { - private readonly ctx: Context + private readonly ctx: ClientContext private readonly host: SettingsScope private themes: ThemeDefinition[] = [...BUILTIN_THEMES] private preference: ThemePreference @@ -165,7 +166,7 @@ export class ThemeRuntime { * media-query and scope listeners are released through ctx.effect on dispose). * @param host - durable preference scope owned by the same plugin. */ - constructor(ctx: Context, host: SettingsScope) { + constructor(ctx: ClientContext, host: SettingsScope) { this.ctx = ctx this.host = host this.preference = DEFAULT_PREFERENCE diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts index e4c76154e5..b616013ac4 100644 --- a/packages/client/ui-theme/src/client/settings-store.ts +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -3,7 +3,7 @@ * plugin's apply-world change listener is the only writer; the row component * reads via props.useStore. */ -import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' import type { ThemePreference } from '../theme-settings.ts' /** Store state mirrored from the theme snapshot. */ diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index d8764a46c6..c42d6aa3b6 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -1,7 +1,9 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx' @@ -23,13 +25,16 @@ function emptySessions() { return bindSnapshotSelector(store) } function emptyWorkspaces() { - const store = createSnapshotStore({ + const store = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) } +type AttentionSnapshot = Parameters[0]>[0] +const noAttention: AttentionSnapshot = new Map() +const useSessionPendingInteraction: AppearanceRowComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) + function mount(preference: ThemePreference = 'system') { // Real store instance — the sanctioned zero-machinery path for tests. const store = createAppearanceRowStore().create() @@ -37,6 +42,7 @@ function mount(preference: ThemePreference = 'system') { const setTheme = vi.fn() const props: AppearanceRowComponentProps = { useSessions: emptySessions(), + useSessionPendingInteraction, useWorkspaces: emptyWorkspaces(), useStore: bindSnapshotSelector(store), actions: store.actions, diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 3f6b1c73ae..65b66c2a39 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -3,7 +3,7 @@ * projection into the row store, and HMR collapse recovery. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -55,11 +55,10 @@ async function bench(isLoopback = true) { }) }) ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) - // The settings transport and the forwarded-event port the plugin injects. - new TestRemote(ctx) + const events = new TestRemote(ctx) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { - ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, + ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, events, setHostPreference: (next: string) => { preference = next }, } } @@ -131,18 +130,18 @@ describe('ui-theme apply', () => { // The shared mirror read once at bench time; a Host-side change reaches it // through the document invalidation, exactly as production announces one. b.setHostPreference('dark') - b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) // The mirror refreshes on every document commit (ns-agnostic); the scope's // derived value only moves when its own namespace changed. - b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0]) + b.events.emit('settings/document-updated', ['unrelated', 0]) await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(3) }) expect(theme.getTheme().preference).toBe('dark') b.setHostPreference('light') - b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') }) b.setHostPreference('dark') b.ctx.emit('connection/reset') @@ -166,7 +165,7 @@ describe('ui-theme apply', () => { b.describe.mockImplementationOnce(() => pending.promise) // The refresh hangs on the wire; the mirror keeps serving the last good // answer, so activation never blocks on the settings transport. - b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() const theme = b.ctx.get('theme') as ThemeRuntime @@ -179,7 +178,7 @@ describe('ui-theme apply', () => { it('ignores an invalid preference crossing the settings wire', async () => { const b = await bench() b.setHostPreference('sepia') - b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) + b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeRuntime await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(2) }) diff --git a/packages/client/ui-theme/tests/invariant.client.spec.ts b/packages/client/ui-theme/tests/invariant.client.spec.ts index b418ce2245..59516d9159 100644 --- a/packages/client/ui-theme/tests/invariant.client.spec.ts +++ b/packages/client/ui-theme/tests/invariant.client.spec.ts @@ -5,7 +5,7 @@ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 83c3924bef..e2f9932485 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -12,7 +12,10 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../store" + }, + { + "path": "../ui-renderer" }, { "path": "../ui-primitives" diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 1f4411367c..8c93e10f21 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -31,9 +31,12 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-workspace-controller/client" + ], "inject": [ + "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -53,16 +56,18 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -71,7 +76,11 @@ "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index a2cc912d0d..dce00a0f54 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,7 +1,9 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { ToolCallTree } from './tool/ToolCallTree.tsx' import { ToolDetails } from './tool/ToolDetails.tsx' import { CONVERSATION_NS as NS } from './locale.ts' diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index a6cdd1fa2d..c9206c8cd9 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,7 +1,7 @@ /** Tool UI slot declarations and their composed component props. */ import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 3ed5fb9216..ebb4578281 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -1,6 +1,6 @@ /** Root/subcall Tool composition with one keyed atomic dispatch path. */ import { memo, useMemo, type ReactNode } from 'react' -import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts' import { GenericToolCard } from './toolviews/GenericToolCard.tsx' import css from './ToolCallTree.module.css' diff --git a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts index e79a6979b8..db31705d6a 100644 --- a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts @@ -13,7 +13,7 @@ * until the result arrives. * @module */ -import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' +import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index e0609191b7..c1adc23b1c 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -8,7 +8,7 @@ * are derived once. * @module */ -import { resolveWorkspacePath } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from './tool-call-model.ts' @@ -169,10 +169,9 @@ function collapse(body: string, rooted: boolean, separator = '/'): string { * returns a generic fenced card for an execution error or a background * start, whose text and error styling the generic path preserves. * - * Window truncation can drop the call head from a settled result (see - * `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal - * result with no call side. That still renders: the command falls back to the - * result view's replacement title, then to an empty command (the prompt line + * Window truncation can drop the call head from a settled `ToolResultNode`, + * leaving a terminal result with no call side. That still renders: the command + * falls back to the result view's replacement title, then to an empty command (the prompt line * draws bare), and the prompt shows no cwd. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param sessionCwd - the session workspace root, which resolves an omitted or diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index 02d3d56f30..394c3ee76e 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -9,10 +9,10 @@ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer // that produces the values). -import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' -import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' -export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +export type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' /** Tool-call row variants selected by the generic atomic renderer. */ export type ToolRowVariant = 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others' diff --git a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx index 7bc7c46447..976ba3df53 100644 --- a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx @@ -9,7 +9,7 @@ */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 44a820a110..f30bb1a3bd 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -3,12 +3,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, waitFor } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TodoItem } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + apply as applyChat, inject as injectChat, type ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' -import { toolChatSnapshot } from './tool-details-render.client.tsx' +import { toolSessionEvents } from './tool-details-render.client.tsx' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -68,22 +73,26 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { + runtime.ctx.provide('connection', { api: { settings: {} }, isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) // ui-theme's Appearance row binds a durable scope through these two. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('remote', { $on: () => () => {} }) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => SID), + openPath: vi.fn(async () => {}), + } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, - snapshot: { nodes, chat: toolChatSnapshot(nodes) }, + events: toolSessionEvents(nodes), session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), @@ -91,6 +100,7 @@ async function bench(nodes: ToolResultNode[]) { }) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) await runtime.mount({ inject: [...injectTool], apply: applyTool }) return runtime } diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index 687720fbef..79b4b84c12 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -1,21 +1,20 @@ // @vitest-environment jsdom -import { Context } from '@deepseek-ai/cordis' -import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' -import { - ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, - EMPTY_CONVERSATION_VIEWS, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' +import { cleanup, fireEvent } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, - ToolCallBlock, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { createSlotRenderer } from '@deepseek-ai/dsh-client-test-runtime' + ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + ConversationEventRegistry, ConversationViewRegistry, type ConvViewOwnerProps, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { en as conversationEn, NS as CONVERSATION_NS, zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' import { toolChatSnapshot } from './tool-details-render.client.tsx' @@ -28,9 +27,12 @@ class ResizeObserverStub { disconnect(): void {} } -afterEach(() => { +const runtimes: SlotTestRuntime[] = [] + +afterEach(async () => { cleanup() vi.unstubAllGlobals() + for (const runtime of runtimes.splice(0)) await runtime.dispose() }) beforeEach(() => { localStorage.clear() @@ -68,118 +70,71 @@ function snapshotWith( nodes: ToolResultNode[], subCalls: readonly ToolCallBlock[], runningCalls: RunningToolCall[] = [], -): ConversationSnapshot { +): ChatSnapshot { const nestedNodes = nodes.map(node => ({ ...node, subCalls })) const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls })) - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: toolChatSnapshot(nestedNodes, nestedRunningCalls), - nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, - runningCalls: nestedRunningCalls, - pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, - openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, - } + return toolChatSnapshot(nestedNodes, nestedRunningCalls) } -/** Test-owned AppFrame role: declares and renders the resident conversation area. */ -type AppRootProps = PropsRenderSlots<'conversation' | 'details'> -function AppRoot({ renderSlot }: AppRootProps) { - return <>{renderSlot('conversation', {})} +/** Test-owned AppFrame role: declares and renders the Chat view list. */ +type AppRootProps = PropsRenderSlots<'conversation.view'> +const VIEW_OWNER: ConvViewOwnerProps = { + viewRequest: null, + openView: () => {}, + completeViewRequest: () => {}, } +function AppRoot({ renderSlot }: AppRootProps) { + return <>{renderSlot('conversation.view', VIEW_OWNER, { only: 'chat' })} +} + +const ROOT_CHILDREN = { + 'conversation.view': { kind: 'list', scope: 'session' }, +} as const /** - * Same real-stack bench as the toolview-slot spec: SlotRegistry + renderer + - * both owning package applies; fakes only at service boundaries. + * Same real-stack bench as the toolview-slot spec: renderer, Chat target, and + * Tool registrations; fakes only at service boundaries. */ -async function bench(snapshot: ConversationSnapshot) { - const ctx = new Context() - const slotsFiber = ctx.plugin(SlotRegistry) - await slotsFiber.await() - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() - const slots = ctx.get('slots') as SlotRegistry +async function bench(snapshot: ChatSnapshot) { + const runtime = await SlotTestRuntime.create() + runtimes.push(runtime) + const ctx = runtime.ctx + const chat = createSnapshotStore(snapshot) + const events = new ConversationEventRegistry(ctx) + const views = new ConversationViewRegistry(ctx) + ctx.provide('uiConversation', { + events, + views, + binding: () => ({ target: () => chat }), + } as never) - const session = createSnapshotStore(snapshot) - const list = createSnapshotStore({ - ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, - current: SID, - phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + await runtime.sessions.add({ + id: SID, + summary: { title: 'S', displayTitle: 'S' }, + snapshot: { running: snapshot.legacy.runningCalls.length > 0 }, }) - const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) } const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } - // Provide-channel contributions land in this bundle the way the runtime - // materializes them; the renderer host serves it through provideInfo. - const provided: { hooks: Record; props: Record } = { hooks: {}, props: {} } - // Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract), - // materialized on first render after the provide contributions landed. - let infoCell: { sessionId: SessionId; hooks: Record; props: Record } | undefined - const sessionsFake = { - list, - binding: (id: SessionId) => (id === SID - ? { sessionId: SID, session, ctx: { effect: () => {}, on: () => () => {} } } - : undefined), - scope: () => ({ get: () => scoped }), - scopeOf: () => SID, - provide: (descriptor: { resolve: (binding: unknown) => { hooks?: Record; props?: Record } }) => { - const contribution = descriptor.resolve(sessionsFake.binding(SID)) - Object.assign(provided.hooks, contribution.hooks ?? {}) - Object.assign(provided.props, contribution.props ?? {}) - return () => {} - }, - provideInfo: (id: string) => (id === SID - ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } - : undefined), - currentProvideInfo: { - getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }, - subscribe: () => () => {}, - }, - create: vi.fn(), - open: vi.fn(), - } - ctx.provide('sessions', sessionsFake) - const workspaces = { - list: createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }), - startSession: vi.fn(), - sendSession: vi.fn(), - openPath: vi.fn(async () => {}), - } - ctx.provide('workspaces', workspaces) - ctx.provide('layout', layout) + const openPath = vi.fn(async () => {}) + ctx.provide('layout', layout as never) + ctx.provide('uiWorkspace', { openPath } as never) ctx.provide('connection', { api: { settings: {} }, isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) - // ui-theme's Appearance row binds a durable scope through these two. - ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) - slots.installLocale(locale) + locale.register(CONVERSATION_NS, { zh: conversationZh, en: conversationEn }) + runtime.slots.installLocale(locale) - slots.install(createSlotRenderer()) - slots.register({ - name: 'root', - children: { - 'conversation': { kind: 'single', scope: 'session-maybe' }, - 'details': { kind: 'single', scope: 'session' }, - }, - }, AppRoot) - - const fiber = ctx.plugin({ inject: [...injectConversation], apply: applyConversation }) - await fiber.await() - const toolFiber = ctx.plugin({ inject: [...injectTool], apply: applyTool }) - await toolFiber.await() - return { ctx, slots, fiber, toolFiber, session, layout, workspaces } + await runtime.root.declare(ROOT_CHILDREN, AppRoot) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) + await runtime.mount({ inject: [...injectTool], apply: applyTool }) + return { runtime, layout, openPath } } -function mountApp(slots: SlotRegistry) { - return render(<>{slots.renderSlot('root', {})}) +function mountApp(runtime: SlotTestRuntime) { + return runtime.renderRoot() } describe('run_code sub-calls through the real chat machinery', () => { @@ -190,7 +145,7 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'), ] const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) // Parent row: the code variant with the model-authored description. const codeRoot = view.container.querySelector('[data-variant="code"]') @@ -214,7 +169,7 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(13, parent, 3, 'cordis_undefine', { id: 'dyn-2' }, 'Dynamic package dyn-2 was discarded.'), ] const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) const nest = view.container.querySelector('[data-subcalls]')! // Each run-control verb names its act and shows the package id; without the @@ -230,7 +185,7 @@ describe('run_code sub-calls through the real chat machinery', () => { it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { const parent = 'call-64' const b = await bench(snapshotWith([codeResult(10, parent)], [])) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) // The code row is expandable via the whole summary row (body = the program). const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]') expect(toggle).not.toBeNull() @@ -249,7 +204,7 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true), ] const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]') expect(nested).not.toBeNull() }) @@ -261,11 +216,11 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), ] const b = await bench(snapshotWith([codeResult(10, parent)], subCalls)) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt') + expect(b.openPath).toHaveBeenCalledWith('notes/demo.txt') }) view.getByText('List notes').click() expect(b.layout.openDetails).not.toHaveBeenCalled() @@ -277,7 +232,7 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), ] const b = await bench(snapshotWith([], subCalls, [runningCode(parent)])) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) const running = view.container.querySelector('[data-variant="code"][data-state="running"]') expect(running).not.toBeNull() const nest = view.container.querySelector('[data-subcalls]') @@ -292,7 +247,7 @@ describe('run_code sub-calls through the real chat machinery', () => { turn: 0, step: 0, time: 21_000, callView: null, subCalls: [], } const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)])) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) // The nested row derives 'running' from the RunningToolCall shape — the // same data-state chrome (row sweep) a native in-flight row wears. const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]') @@ -308,7 +263,7 @@ describe('run_code sub-calls through the real chat machinery', () => { content: [], isError: false, callView: null, resultView: null, subCalls: [], } const b = await bench(snapshotWith([plain], [])) - const view = mountApp(b.slots) + const view = mountApp(b.runtime) expect(view.container.querySelector('[data-subcalls]')).toBeNull() }) }) diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 61b4fc576b..7c20a7c91f 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -2,9 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index e4cf03c044..89e2ae6529 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -2,24 +2,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/diff-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' afterEach(cleanup) @@ -28,6 +30,7 @@ type FileMutationRowProps = Parameters[0] const SID = 's1' as SessionId const t = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}' @@ -301,7 +304,7 @@ describe('fileMutationToolview registration', () => { }) describe('DetailsPanel diff Output section', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -315,18 +318,22 @@ describe('DetailsPanel diff Output section', () => { subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, @@ -339,22 +346,18 @@ describe('DetailsPanel diff Output section', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} - t={t} + t={chatT} />, ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'edit' } diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 400b8eecdd..16b957cc87 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -3,24 +3,25 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { Context } from '@deepseek-ai/cordis' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' + bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' afterEach(cleanup) @@ -28,6 +29,7 @@ const SID = 's1' as SessionId /** The chat-view locale seat: this package's namespace over the common fallback. */ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) // The read tool's real schema key is `file_path`; the top-level read samples // use it so the row exercises a production-shaped call. `web_fetch` (below) has @@ -256,7 +258,7 @@ describe('ReadRow keyed toolview', () => { describe('DetailsPanel Output section (read)', () => { function mount( - snapshot: ConversationSnapshot, + snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string, description?: Parameters[1], @@ -274,19 +276,23 @@ describe('DetailsPanel Output section (read)', () => { subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - t={t} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + t={chatT} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, @@ -303,17 +309,13 @@ describe('DetailsPanel Output section (read)', () => { ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' } diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index b2064e4b3f..d1b3d8a1e0 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -2,30 +2,33 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/tool/models/search-card-model.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' type SearchRowProps = Parameters[0] afterEach(cleanup) const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) /** The rendered search card's kind attribute, so a render site cannot silently drop it. */ function searchKindOf(container: HTMLElement): string | null { @@ -369,7 +372,7 @@ describe('SearchRow keyed card', () => { }) describe('DetailsPanel Output section (search)', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -377,18 +380,22 @@ describe('DetailsPanel Output section (search)', () => { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, @@ -401,22 +408,18 @@ describe('DetailsPanel Output section (search)', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} - t={t} + t={chatT} />, ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } const grepTarget: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'grep' } diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 4d6eeeacd8..52e7b8897e 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -2,28 +2,31 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' type BashRowProps = Parameters[0] const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) afterEach(cleanup) @@ -438,7 +441,7 @@ describe('BashRow terminal card', () => { }) describe('DetailsPanel Output section', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -452,40 +455,40 @@ describe('DetailsPanel Output section', () => { subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} - t={t} + t={chatT} />, ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' } @@ -635,28 +638,33 @@ describe('DetailsPanel Output section', () => { const chat = createChatStore().create() const closeDetails = vi.fn() const snap = snapshot() + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) const view = render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }))} - useWorkspaces={bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }))} + useSessionPendingInteraction={bindSnapshotSelector(attention)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={closeDetails} - t={t} + t={chatT} />, ) fireEvent.click(view.getByRole('button', { name: '关闭详情' })) diff --git a/packages/client/ui-tool/tests/todo-row.client.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx index db720ed056..66739e7149 100644 --- a/packages/client/ui-tool/tests/todo-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/todo-row.client.spec.tsx @@ -2,7 +2,8 @@ /** todo_write atomic Tool presentation and its plan-summary model. */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { TodoItem } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { TodoRow, todoToolview } from '../src/client/tool/toolviews/todo-row.tsx' diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 7f18800303..77fca2e7da 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' -import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ToolTreeProps } from '../src/client/contract/slots.ts' @@ -24,8 +25,8 @@ function props( selectedCallId?: string, description?: HostDescription, ): ToolTreeProps { - const snapshot = {} as ConversationSnapshot - const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession'] + const snapshot = {} as SessionSnapshot + const useSession = ((selector: (value: SessionSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession'] const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) => options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot'] return { diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index c0332e9ac6..5d8b36e722 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,14 +1,38 @@ /** Test adapter for the production conversation.details.tool registration. */ import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionEventEntry, SessionToolCallView } from '@deepseek-ai/dsh-api-session-controller/types' +import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' import type { - ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' + ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps, + DetailsToolOwnerProps, RunningToolCall, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' -/** Framework session-area seat used by direct DetailsPanel tests. */ -export const SessionProviderStub: SessionProviderComponent = ({ children }) => children('s1' as SessionId) +type TrajectorySnapshot = Parameters[0]>[0] + +const emptyTrajectory: TrajectorySnapshot = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} + +/** Stable empty Trajectory source for DetailsPanel fixtures. */ +export const useEmptyTrajectory: DetailsSlotProps['useTrajectory'] = selector => selector(emptyTrajectory) + +function jsonFixture(value: unknown): JsonValue { + if (!isJsonValue(value)) throw new Error('tool event fixture must be lossless JSON') + return value as JsonValue +} + +function sessionCallView(view: NonNullable): SessionToolCallView { + if (view.card !== 'generic') return view + const { rawInput, ...wireView } = view + return rawInput === undefined ? wireView : { ...wireView, rawInput: jsonFixture(rawInput) } +} /** Build the canonical Chat slice consumed by Tool rows and details tests. */ export function toolChatSnapshot( @@ -49,6 +73,77 @@ export function toolChatSnapshot( } } +/** Build the Session event window that projects settled root Tool calls into Chat. */ +export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly SessionEventEntry[] { + const firstTime = nodes[0]?.callTime ?? nodes[0]?.time ?? 0 + const entries: SessionEventEntry[] = [ + { + event: { + seq: 1, + time: firstTime - 2, + type: 'turn/start', + data: { turn: 1 }, + }, + }, + { + event: { + seq: 2, + time: firstTime - 1, + type: 'step/start', + data: { turn: 1, step: 1 }, + }, + }, + ] + for (const [index, node] of nodes.entries()) { + if (node.call === null) throw new Error(`tool fixture "${node.callId}" requires its call event`) + const callSeq = 3 + index * 2 + const callEntry: SessionEventEntry = { + event: { + seq: callSeq, + time: node.callTime ?? node.time - 1, + type: 'tool/call', + data: { + turn: 1, + step: 1, + callId: node.callId, + name: node.call.name, + arguments: node.call.argsRaw, + }, + }, + ...(node.callView === null ? {} : { view: { for: 'call', view: sessionCallView(node.callView) } }), + } + entries.push(callEntry) + const resultEntry: SessionEventEntry = { + event: { + seq: callSeq + 1, + time: node.time, + type: 'tool/result', + data: jsonFixture({ + turn: 1, + step: 1, + message: { + id: `result-${node.callId}`, + role: 'user', + source: { kind: 'tool', callId: node.callId }, + content: [{ + type: 'tool-result', + toolCallId: node.callId, + content: node.content.map(block => ({ ...block })), + isError: node.isError, + }], + }, + ...(node.error === undefined ? {} : { error: node.error }), + ...(node.meta === undefined ? {} : { meta: node.meta }), + }), + surfaceOp: 'append', + }, + ...(node.resultView === null ? {} : { view: { for: 'result', view: node.resultView } }), + } + entries.push(resultEntry) + } + return entries +} + /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index d311453c3d..f0eab2d1d1 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts' diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index c14575b236..2d9c2eb2d0 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -2,14 +2,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup } from '@testing-library/react' -import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + apply as applyChat, inject as injectChat, type ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' -import { toolChatSnapshot } from './tool-details-render.client.tsx' +import { toolSessionEvents } from './tool-details-render.client.tsx' const SID = 's1' as SessionId @@ -55,23 +59,29 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { + runtime.ctx.provide('connection', { api: { settings: {} }, isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) // ui-theme's Appearance row binds a durable scope through these two. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('remote', { $on: () => () => {} }) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } - runtime.provide('layout', layout) + runtime.ctx.provide('layout', layout) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => SID), + openPath: async (path: string) => { + runtime.workspaces.calls.push({ method: 'openPath', args: [path] }) + }, + } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S' }, - snapshot: { nodes, chat: toolChatSnapshot(nodes) }, + events: toolSessionEvents(nodes), session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), @@ -79,6 +89,7 @@ async function bench(nodes: ToolResultNode[]) { }) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) await runtime.mount({ inject: [...injectTool], apply: applyTool }) return { runtime, slots: runtime.slots, layout } } @@ -198,17 +209,21 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { + runtime.ctx.provide('connection', { api: { settings: {} }, isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) // ui-theme's Appearance row binds a durable scope through these two. - runtime.provide('remote', { $on: () => () => {} }) - runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('remote', { $on: () => () => {} }) + runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.ctx.provide('uiWorkspace', { + connectWorkspace: vi.fn(async () => SID), + openPath: vi.fn(async () => {}), + } as never) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) @@ -232,6 +247,7 @@ describe('registrant declaration injection', () => { // Mounting the package declares the slot and activates the waiting entry. await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectChat], apply: applyChat }) await runtime.mount({ inject: [...injectTool], apply: applyTool }) expect(runtime.slots.entries('tool.call.toolview').map(e => e.options.key)) .toEqual(expect.arrayContaining(['bash', 'late'])) diff --git a/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx b/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx index 5fa250433c..e3c80fc5e4 100644 --- a/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx @@ -2,7 +2,7 @@ // atomic-view props. Generic slot-system duals live in ui-slots tests. import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { ToolCallViewProps } from '../src/client/contract/slots.ts' describe('toolview type negatives (compile-time; body never runs)', () => { diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index f9712c091e..1896fd8f79 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -2,32 +2,35 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { - createSnapshotStore, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { webCardModel } from '../src/client/tool/models/web-card-model.ts' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts' import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' -import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.client.tsx' +import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts' afterEach(cleanup) const SID = 's1' as SessionId const t = makeTranslate(zh, commonZh) +const chatT = makeTranslate(chatZh, commonZh) const SEARCH_ARGS = '{"query":"deepseek harness"}' const FETCH_ARGS = '{"url":"https://example.com/page"}' @@ -196,7 +199,7 @@ describe('chat row web body', () => { }) describe('DetailsPanel web Output section', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -204,18 +207,22 @@ describe('DetailsPanel web Output section', () => { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) + const session = createSnapshotStore(sessionSnapshot(SID)) + const conversation = createSnapshotStore(conversationSnapshot()) + const workspaces = createSnapshotStore(workspaceSnapshot()) + const attention = createSnapshotStore(new Map()) return render( children} sessionId={SID} - useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useSession={bindSnapshotSelector(session)} useSessions={bindSnapshotSelector(sessions)} + useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} + useConversation={bindSnapshotSelector(conversation)} + useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} + useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, @@ -228,22 +235,18 @@ describe('DetailsPanel web Output section', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} - t={t} + t={chatT} />, ) } - function snapshot(over: Partial = {}): ConversationSnapshot { + function snapshot(over: { + nodes?: readonly ConversationNode[] + runningCalls?: readonly RunningToolCall[] + } = {}): ChatSnapshot { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] - return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, - chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), - nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], - pending: [], queue: [], running: false, composerPhase: 'active', removed: false, - openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, - } + return toolChatSnapshot(nodes, runningCalls) } it('renders the search card at full source allowance', () => { diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 1982d6eee1..7b30739361 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/workspace-controller/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -18,10 +21,10 @@ "path": "../connection/tsconfig.client.json" }, { - "path": "../runtime" + "path": "../locale" }, { - "path": "../locale" + "path": "../ui-chat" }, { "path": "../ui-conversation" @@ -29,11 +32,20 @@ { "path": "../ui-primitives" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-slots" }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../core/tools" } ] } diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index afb1ebf40b..e55e7a09df 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -31,10 +31,15 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-client-ui-conversation/client" + ], "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } @@ -51,17 +56,19 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^" + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -73,7 +80,13 @@ "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index dab72e6778..c2f63d5789 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -15,7 +15,7 @@ import { import { structuredPatch } from 'diff' import type { AssistantRequestConfig, ConversationPromptSnapshot, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 36727078ef..ceb137795e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,12 +1,11 @@ /** Trajectory view: compact summary over a turn-aware event ledger. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { - AssistantBlock, AssistantMessageNode, ConversationSnapshot, - SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' + AssistantBlock, AssistantMessageNode, ConvViewProps, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { TrajectoryTable, type TrajectoryRequestNumber, @@ -25,7 +24,7 @@ import { } from './timeline.ts' import { trajectoryRecordId } from './trajectory-record.ts' import { TrajectorySearchIndex } from './trajectory-search-index.ts' -import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts' +import type { TrajectorySnapshot } from './trajectory-contract.ts' import css from './views.module.css' const EMPTY_TURN_IDS: ReadonlySet = new Set() @@ -57,7 +56,7 @@ function timelineBlock(block: AssistantBlock): AssistantBlock { } } -function partialStructureSignature(partial: ConversationSnapshot['partial']): string { +function partialStructureSignature(partial: TrajectorySnapshot['partial']): string { if (partial === null) return '' return partial.blocks.map(block => block.kind === 'tool-call' ? `${block.kind}:${block.callId}:${block.name}` @@ -118,8 +117,8 @@ function addUsage( } export function TrajectoryView({ - useSession, useDuration, loadOlder, setActualDuration, - inspect, onInspectDone, t, + useSession, useTrajectory, useDuration, loadOlder, setActualDuration, + viewRequest, completeViewRequest, t, }: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = @@ -139,8 +138,7 @@ export function TrajectoryView({ const [timelineRecordFocus, setTimelineRecordFocus] = useState<{ readonly index: number } | null>(null) - const inspection = useSession(snapshot => - snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT) + const inspection = useTrajectory(snapshot => snapshot) const historyLoading = useSession(snapshot => snapshot.openState === 'loading') const olderHistoryLoading = useSession(snapshot => snapshot.loadingOlder) const hasOlderHistory = useSession(snapshot => snapshot.hasMore) @@ -151,6 +149,7 @@ export function TrajectoryView({ const runningCalls = inspection.runningCalls const requests = inspection.requests const callSchemas = inspection.callSchemas + const inspectCallId = viewRequest?.view === 'trajectory' ? viewRequest.focus : null const requestNumbers = useMemo(() => { const assistantsByStep = new Map() for (const node of nodes) { @@ -269,7 +268,7 @@ export function TrajectoryView({ runningCalls, requests, callSchemas, ]) const timelinePartialSignature = partialStructureSignature(partial) - const timelinePartial = useMemo(() => partial === null + const timelinePartial = useMemo(() => partial === null ? null : { turn: partial.turn, @@ -497,8 +496,8 @@ export function TrajectoryView({ onToggleTurn={toggleTurn} collapsedAssistants={collapsedAssistants} onToggleAssistant={toggleAssistant} - inspectCallId={inspect?.callId ?? null} - onInspectApplied={onInspectDone} + inspectCallId={inspectCallId} + onInspectApplied={completeViewRequest} />
diff --git a/packages/client/ui-trajectory/src/client/duration-store.ts b/packages/client/ui-trajectory/src/client/duration-store.ts index f965dff2d8..dbe623332e 100644 --- a/packages/client/ui-trajectory/src/client/duration-store.ts +++ b/packages/client/ui-trajectory/src/client/duration-store.ts @@ -1,6 +1,6 @@ import { createSnapshotStore, type SnapshotStore, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-store' /** * Create the browser-wide trajectory duration preference source. diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index ef789dce30..d481f7ce9d 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -3,24 +3,40 @@ * slot without defining a service. */ import type { Context } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the 'conversation.view' SlotMap row (declared by the slot's // owning package) must be in the program for the register calls to type. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { en, NS, zh } from './locales.ts' import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts' import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts' import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts' import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts' -import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts' +import { + EMPTY_TRAJECTORY_SNAPSHOT, registerTrajectoryConversationView, +} from './trajectory-snapshot-builder.ts' +import type { TrajectorySnapshot } from './trajectory-contract.ts' import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' +export type { TrajectoryKey } from './locales.ts' +export type { + TrajectoryContribution, + TrajectoryConversationViewNode, + TrajectoryRequestHeaderState, + TrajectorySnapshot, + UseTrajectory, +} from './trajectory-contract.ts' + /** Required services: the conversation slot, registries, ordinary Session paging, and the locale service. */ -export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale'] +export const inject = ['slots', 'sessions', 'uiSession', 'uiConversation', 'locale'] /** * Client plugin body: register the trajectory view tab. The registration @@ -28,6 +44,19 @@ export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sess * @param ctx - client root context. */ export function apply(ctx: Context): void { + const trajectorySources = new WeakMap>() + const trajectorySource = (binding: SessionBinding): ObservableSnapshot => { + let source = trajectorySources.get(binding) + if (source === undefined) { + const target = ctx.uiConversation.binding(binding).target('trajectory') + source = { + getSnapshot: () => target.getSnapshot() ?? EMPTY_TRAJECTORY_SNAPSHOT, + subscribe: listener => target.subscribe(listener), + } + trajectorySources.set(binding, source) + } + return source + } ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-trajectory: dictionaries') // Registration-time text (the view tab label) reads through the bound // translate as a thunk, so it follows the active locale without @@ -40,6 +69,10 @@ export function apply(ctx: Context): void { registerTrajectoryToolDefinition(ctx) registerTrajectoryCompactionDefinitions(ctx) registerTrajectoryConversationView(ctx) + ctx.uiSession.provide({ + hooks: ['trajectory'], + resolve: binding => ({ hooks: { trajectory: trajectorySource(binding) } }), + }) ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', @@ -51,12 +84,13 @@ export function apply(ctx: Context): void { if (session === undefined) { throw new Error(`ui-trajectory: session "${sessionId}" is unavailable`) } + const trajectory = ctx.uiConversation.binding(sessionId).target('trajectory') return { hooks: { duration }, loadOlder: async () => { - const before = session.getSnapshot().views.get('trajectory') + const before = trajectory.getSnapshot() await session.loadOlder() - return session.getSnapshot().views.get('trajectory') !== before + return trajectory.getSnapshot() !== before }, setActualDuration: (value) => { duration.set(value) }, } diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 265d5ba034..da766a5b9b 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -6,17 +6,17 @@ import type { AssistantBlock, AssistantMessageNode, ConversationLocation, - ConversationSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, ToolCallBlock, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' +import type { TrajectorySnapshot } from './trajectory-contract.ts' import { formatElapsedSeconds } from './trajectory-record.ts' /** One Message or Step group inside a turn. */ @@ -34,10 +34,10 @@ export interface TrajectoryTurnModel { /** Snapshot slice the trajectory view folds. */ export interface TrajectoryLayoutInput { - nodes: ConversationSnapshot['nodes'] + nodes: TrajectorySnapshot['eventNodes'] eventLocations?: ReadonlyMap - partial: ConversationSnapshot['partial'] - runningCalls: ConversationSnapshot['runningCalls'] + partial: TrajectorySnapshot['partial'] + runningCalls: TrajectorySnapshot['runningCalls'] requests?: readonly RequestView[] callSchemas?: RequestInspectionSnapshot['callSchemas'] } @@ -72,7 +72,7 @@ type AssistantRequestView = Extract type CompactionRequestView = Extract type InputNode = Extract< - ConversationSnapshot['nodes'][number], + TrajectorySnapshot['eventNodes'][number], { kind: 'user' | 'steering' | 'context' } > @@ -80,7 +80,7 @@ type OrderedLayoutEntry = | { kind: 'node' seq: number - node: ConversationSnapshot['nodes'][number] + node: TrajectorySnapshot['eventNodes'][number] nodeIndex: number } | { @@ -531,7 +531,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T */ export function appendTrajectoryPartialLayout( turns: readonly TrajectoryTurnModel[], - partial: ConversationSnapshot['partial'], + partial: TrajectorySnapshot['partial'], lastIndex: number, ): readonly TrajectoryTurnModel[] { if (partial === null) return turns @@ -858,7 +858,7 @@ function stringifySourceValue(value: unknown): string { */ function enclosingUserTurn( followingAssistant: AssistantMessageNode | undefined, - partial: ConversationSnapshot['partial'], + partial: TrajectorySnapshot['partial'], lastAssistantTurn: number | null, ): number { if (followingAssistant !== undefined) return followingAssistant.turn @@ -869,7 +869,7 @@ function enclosingUserTurn( function steeringPlacement( followingAssistant: AssistantMessageNode | undefined, - partial: ConversationSnapshot['partial'], + partial: TrajectorySnapshot['partial'], lastAssistantTurn: number | null, location: ConversationLocation | undefined, ): { turn: number; step?: number } { @@ -892,7 +892,7 @@ function steeringPlacement( } function indexFollowingAssistants( - nodes: ConversationSnapshot['nodes'], + nodes: TrajectorySnapshot['eventNodes'], ): readonly (AssistantMessageNode | undefined)[] { const following = new Array(nodes.length) let assistant: AssistantMessageNode | undefined @@ -905,9 +905,9 @@ function indexFollowingAssistants( } function enclosingPromptTurn( - nodes: ConversationSnapshot['nodes'], + nodes: TrajectorySnapshot['eventNodes'], seq: number, - partial: ConversationSnapshot['partial'], + partial: TrajectorySnapshot['partial'], ): number { const next = nodes.find(node => node.seq > seq && node.kind === 'assistant' && node.step > 0) @@ -917,8 +917,8 @@ function enclosingPromptTurn( /** Earliest raw turn represented by the selected trajectory branch. */ function firstVisibleTurn( - nodes: ConversationSnapshot['nodes'], - partial: ConversationSnapshot['partial'], + nodes: TrajectorySnapshot['eventNodes'], + partial: TrajectorySnapshot['partial'], ): number { const turns = nodes.flatMap(node => node.kind === 'assistant' && node.turn > 0 @@ -939,7 +939,7 @@ function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): v if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens } -function indexResults(nodes: ConversationSnapshot['nodes']): Map { +function indexResults(nodes: TrajectorySnapshot['eventNodes']): Map { const map = new Map() for (const node of nodes) { if (node.kind === 'tool-result') map.set(node.callId, node) @@ -947,7 +947,7 @@ function indexResults(nodes: ConversationSnapshot['nodes']): Map { +function indexAssistantCallIds(nodes: TrajectorySnapshot['eventNodes']): ReadonlySet { const ids = new Set() for (const node of nodes) { if (node.kind !== 'assistant') continue diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 0013315f96..e64f8350f8 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -1,12 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' -import type { - AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, - ConversationNodeContext, ConversationNodeDefinition, PartialAssistant, RequestView, -} from '@deepseek-ai/dsh-client-runtime/client' import { displayFailureMessage, emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks, -} from '@deepseek-ai/dsh-client-runtime/client' + type AssistantBlock, type AssistantMessageNode, type ConversationLocation, + type ConversationMatch, type ConversationNodeContext, type ConversationNodeDefinition, + type PartialAssistant, type RequestView, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { trajectoryNode } from './trajectory-definition-common.ts' /* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event @@ -402,6 +401,6 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition = { * @param ctx - Plugin context receiving the Definitions. */ export function registerTrajectoryAssistantDefinition(ctx: Context): void { - ctx.conversationEvents.register(trajectoryAssistantDefinition) - ctx.conversationEvents.register(trajectoryTurnEndDefinition) + ctx.uiConversation.events.register(trajectoryAssistantDefinition) + ctx.uiConversation.events.register(trajectoryTurnEndDefinition) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts index c48023440b..c5487bbb91 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts @@ -1,7 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeDefinition, RequestView, -} from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' import { trajectoryNode } from './trajectory-definition-common.ts' @@ -138,6 +138,6 @@ const trajectorySessionEndDefinition: ConversationNodeDefinition + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationViewSnapshotMap { /** Independently assembled data consumed by the Trajectory view. */ trajectory: TrajectorySnapshot } } + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SessionStandardProps { + /** Selector hook over the current Conversation binding's Trajectory target. */ + useTrajectory: UseTrajectory + } +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts index d55d5ca542..5d2d897937 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -1,4 +1,4 @@ -import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryContribution, TrajectoryConversationViewNode, } from './trajectory-contract.ts' diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index 4139a318db..2dfc871ad4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -1,11 +1,9 @@ import type { Context } from '@deepseek-ai/cordis' -import type { - ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext, - SteeringMessageNode, UserMessageNode, -} from '@deepseek-ai/dsh-client-runtime/client' import { contextForm, contextProvenance, -} from '@deepseek-ai/dsh-client-runtime/client' + type ContextMessageNode, type ConversationNodeDefinition, type ConversationPreviousContext, + type SteeringMessageNode, type UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-agent/types' import { trajectoryNode } from './trajectory-definition-common.ts' @@ -117,6 +115,6 @@ const trajectoryMessageDefinition: ConversationNodeDefinition = { * @param ctx - Plugin context receiving the Definitions. */ export function registerTrajectoryMessageDefinitions(ctx: Context): void { - ctx.conversationEvents.register(trajectoryInboxDefinition) - ctx.conversationEvents.register(trajectoryMessageDefinition) + ctx.uiConversation.events.register(trajectoryInboxDefinition) + ctx.uiConversation.events.register(trajectoryMessageDefinition) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index e4cd6e6e02..fe054dcadb 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -1,7 +1,7 @@ /** Shared trajectory record data and formatting contracts. */ import type { HTMLAttributes } from 'react' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' /** Closed set of trajectory record kinds. */ export type TrajectoryCellKind = diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts index 20a4d437e9..0cbb9fee77 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, - RequestPromptChange, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, RequestPromptChange, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { trajectoryNode } from './trajectory-definition-common.ts' import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts' @@ -76,5 +75,5 @@ const trajectoryRequestHeaderDefinition: ConversationNodeDefinition = { * @param ctx - Plugin context receiving the Definition. */ export function registerTrajectoryToolDefinition(ctx: Context): void { - ctx.conversationEvents.register(trajectoryToolDefinition) + ctx.uiConversation.events.register(trajectoryToolDefinition) } diff --git a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts index 25b13718aa..e1ca41bfb2 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts @@ -11,9 +11,8 @@ import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, describe, expect, it } from 'vitest' -import { - ConversationEventRegistry, ConversationViewRegistry, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' +import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory' @@ -50,7 +49,8 @@ describe('tsdown client artifact', () => { ['react', await import('react')], ['react/jsx-runtime', await import('react/jsx-runtime')], ['react-dom', await import('react-dom')], - ['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')], + ['@deepseek-ai/dsh-client-store', await import('@deepseek-ai/dsh-client-store')], + ['@deepseek-ai/dsh-client-ui-conversation/client', await import('@deepseek-ai/dsh-client-ui-conversation/client')], ['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')], ]) const exports = handoff!.factory((spec) => { @@ -65,7 +65,7 @@ describe('tsdown client artifact', () => { expect(handoff.id).toBe(PLUGIN_ID) expect(exports.apply).toBeTypeOf('function') expect(exports.inject).toEqual([ - 'slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale', + 'slots', 'sessions', 'uiSession', 'uiConversation', 'locale', ]) }) @@ -73,8 +73,7 @@ describe('tsdown client artifact', () => { const { exports } = await loadArtifact() const ctx = new Context() const slots = new SlotRegistry(ctx) - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() + ctx.provide('uiSession', { provide: () => () => {} } as never) // The conversation entry's role: the ring must be declared before riders land. slots.register({ name: 'root', @@ -84,7 +83,10 @@ describe('tsdown client artifact', () => { // entry, so the binding stays deliberately empty. The locale plugin backs // the locale-aware view tab label (its settings scope needs a connection // handle and the Host-facing settings/remote seams). - ctx.provide('sessions', { binding: () => undefined }) + const sessions = { binding: () => undefined } + ctx.provide('sessions', sessions) + const uiConversation = new UiConversation(ctx, sessions as never) + const { events, views } = uiConversation ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -92,8 +94,6 @@ describe('tsdown client artifact', () => { ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void }) await fiber.await() - const events = ctx.get('conversationEvents') as ConversationEventRegistry - const views = ctx.get('conversationViews') as ConversationViewRegistry expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) expect(events.entries().length).toBeGreaterThan(0) expect(views.entries()).toHaveLength(1) diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index 9b095cae7f..8d0d806788 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -2,8 +2,8 @@ import type { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' -import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts' import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts' import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' @@ -14,10 +14,12 @@ import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool- const DEFINITIONS: ConversationNodeDefinition[] = [] const registrationContext = { - conversationEvents: { - register: (definition: ConversationNodeDefinition) => { - DEFINITIONS.push(definition) - return () => {} + uiConversation: { + events: { + register: (definition: ConversationNodeDefinition) => { + DEFINITIONS.push(definition) + return () => {} + }, }, }, } as unknown as Context @@ -58,7 +60,6 @@ function at( data, ...extra, } as unknown as ConversationEventInput['event'], - view: undefined, } } @@ -73,7 +74,7 @@ function assembler(events: readonly ConversationEventInput[]): ConversationNodeA } function snapshot(value: ConversationNodeAssembler): TrajectorySnapshot { - const current = value.snapshot('trajectory') as TrajectorySnapshot | undefined + const current = value.get('trajectory') if (current === undefined) throw new Error('trajectory view was not registered') return current } diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index ec8924505f..70ff2c2e1b 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -6,8 +6,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' import type { - ConversationLocation, ConversationSnapshot, RequestView, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationNode, RequestView, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' @@ -15,6 +15,10 @@ import { appendTrajectoryPartialLayout, deriveTrajectoryLayout, } from '../src/client/layout.ts' +interface LegacyConversationSlice { + readonly nodes: readonly ConversationNode[] +} + afterEach(cleanup) describe('TrajectoryTurnHeader', () => { @@ -73,7 +77,7 @@ describe('deriveTrajectoryLayout', () => { call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200, content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns).toHaveLength(1) expect(turns[0]?.turn).toBe(1) @@ -113,7 +117,7 @@ describe('deriveTrajectoryLayout', () => { const nodes = [{ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'finalized' }], - }] as unknown as ConversationSnapshot['nodes'] + }] as unknown as LegacyConversationSlice['nodes'] const partial = { turn: 2, step: 1, @@ -183,7 +187,7 @@ describe('deriveTrajectoryLayout', () => { ], usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 }, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const cells = turns[0]?.groups.flatMap(g => g.cells) ?? [] expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull() @@ -209,7 +213,7 @@ describe('deriveTrajectoryLayout', () => { call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600, content: [], isError: false, callView: null, resultView: null, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2') }) @@ -226,7 +230,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0, blocks: [{ kind: 'text', text: 'ok2' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns.map(t => t.turn)).toEqual([1, 2]) expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([ @@ -254,7 +258,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'second step' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const data = { get: () => undefined } const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data } const turn = { @@ -286,7 +290,7 @@ describe('deriveTrajectoryLayout', () => { const nodes = [{ kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000, content: [{ type: 'text', text: 'change direction' }], source: null, - }] as unknown as ConversationSnapshot['nodes'] + }] as unknown as LegacyConversationSlice['nodes'] const data = { get: () => undefined } const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data } const turn = { @@ -329,7 +333,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 3, blocks: [{ kind: 'text', text: 'continued' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) @@ -357,7 +361,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'after compaction' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const compaction: RequestView = { purpose: 'compaction', startSeq: 3, @@ -395,7 +399,7 @@ describe('deriveTrajectoryLayout', () => { blocks: [{ kind: 'reasoning', text: '…' }], usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 }, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') expect(message).toMatchObject({ @@ -408,7 +412,7 @@ describe('deriveTrajectoryLayout', () => { const nodes = [{ kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0, blocks: [{ kind: 'reasoning', text: thinking }], - }] as unknown as ConversationSnapshot['nodes'] + }] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [], @@ -447,7 +451,7 @@ describe('deriveTrajectoryLayout', () => { kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0, blocks: [{ kind: 'text', text: 'done' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const cells = turns[0]?.groups.flatMap(g => g.cells) ?? [] const message = cells.find(c => c.kind === 'message' && c.previewMarkdown === 'done') @@ -465,7 +469,7 @@ describe('deriveTrajectoryLayout', () => { blocks: [{ kind: 'text', text: 'done' }], timing: { stepStartTime: 3_000, firstTokenTime: 3_500, completedTime: 4_000 }, }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [], }) @@ -489,7 +493,7 @@ describe('run_code sub-dispatch cells', () => { content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null, subCalls: [], }, - ] as unknown as ConversationSnapshot['nodes'] + ] as unknown as LegacyConversationSlice['nodes'] const settledSub = (n: number, name: string, start: number, end: number) => ({ kind: 'tool-result' as const, seq: 100 + n, time: end, @@ -500,7 +504,7 @@ describe('run_code sub-dispatch cells', () => { }) const withSubCalls = (subCalls: readonly ReturnType[] | readonly object[]) => - runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as ConversationSnapshot['nodes'] + runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as LegacyConversationSlice['nodes'] it('nests settled sub-cells after their parent Tool cell with real durations', () => { const subCalls = [ diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts index c0058b75c6..8d6da1f04f 100644 --- a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts +++ b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { RequestView } from '@deepseek-ai/dsh-client-runtime/client' +import type { RequestView } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryContribution, TrajectoryConversationViewNode, TrajectoryRequestHeaderState, } from '../src/client/trajectory-contract.ts' diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 3df145b757..6d63267583 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -7,27 +7,36 @@ * event ledger with its timing overview, and fiber disposal removes the tab. * Timeline projection and inclusive focus edge cases ride along. */ -import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { - ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, - EMPTY_CHAT_SNAPSHOT, -} from '@deepseek-ai/dsh-client-runtime/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' + EMPTY_CONVERSATION_SNAPSHOT, UiConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { - ConversationSnapshot, RequestView, - SessionId, SessionListState, SnapshotStore, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' + ConversationBinding, ConversationSnapshot, ConversationViewSnapshotMap, ConvViewProps, + InputActions, InputState, RequestView, ViewTab, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { + ChatSnapshot, LegacyConversationSlice, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { + SessionBinding, SessionListState, SessionProjectionMap, SessionSnapshot, UseProjection, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { ConversationSession, ConversationSessionHeader, type ConversationSessionHeaderProps, type ConversationSessionProps, } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' -import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { createConversationStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' @@ -41,23 +50,28 @@ import { TrajectoryView, type TrajectoryViewInjected, } from '../src/client/TrajectoryView.tsx' import { createTrajectoryDurationStore } from '../src/client/duration-store.ts' +import { EMPTY_TRAJECTORY_SNAPSHOT } from '../src/client/trajectory-snapshot-builder.ts' import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId -const sessionSnapshots = new WeakMap>() const tConversation: ConversationSessionHeaderProps['t'] = key => (conversationZh as Record)[key] ?? key -afterEach(cleanup) -// The chat store persists under its declared key; clear so one case's active +const runtimes: SlotTestRuntime[] = [] + +afterEach(async () => { + cleanup() + for (const runtime of runtimes.splice(0)) await runtime.dispose() +}) +// The Conversation store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. beforeEach(() => { localStorage.clear() }) /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ -const NODES = [ +const NODES: LegacyConversationSlice['nodes'] = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [], @@ -65,19 +79,19 @@ const NODES = [ }, { kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200, - content: [], isError: false, callView: null, resultView: null, + content: [], isError: false, callView: null, resultView: null, subCalls: [], }, { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [], timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 }, }, -] as unknown as ConversationSnapshot['nodes'] +] function historySnapshot( - nodes: ConversationSnapshot['nodes'], + nodes: LegacyConversationSlice['nodes'], inspection: Partial = {}, -): ConversationSnapshot { - const trajectory: TrajectorySnapshot = { +): TrajectorySnapshot { + return { eventNodes: nodes, eventLocations: new Map(), requests: [], @@ -86,22 +100,14 @@ function historySnapshot( runningCalls: [], ...inspection, } +} + +function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapshot { return { sessionId: SID, - views: { - get: target => target === 'trajectory' ? trajectory : undefined, - }, - chat: EMPTY_CHAT_SNAPSHOT, - nodes, - turnTimings: new Map(), - turnEnds: new Map(), - partial: trajectory.partial, - runningCalls: trajectory.runningCalls, - pending: [], queue: [], running: false, subagent: null, - composerPhase: 'active', removed: false, openState: 'open', openError: null, @@ -110,18 +116,33 @@ function historySnapshot( promptError: null, blank: nodes.length === 0, lastAgentError: null, + promptAttempted: nodes.length > 0, + awaitingFirstTurn: false, + } +} + +function conversationSnapshot( + trajectory: TrajectorySnapshot, +): ConversationSnapshot { + return { + views: EMPTY_CONVERSATION_SNAPSHOT.views, + activeTargets: trajectory.eventNodes.length === 0 + ? new Set() + : new Set(['trajectory']), } } function standaloneHistory( - snapshot: ConversationSnapshot, + snapshot: TrajectorySnapshot, ): Pick< ComponentProps, - 'useSession' | 'loadOlder' + 'useSession' | 'useTrajectory' | 'loadOlder' > { - const store = createSnapshotStore(snapshot) + const session = createSnapshotStore(sessionSnapshot(snapshot.eventNodes)) + const trajectory = createSnapshotStore(snapshot) return { - useSession: bindSnapshotSelector(store), + useSession: bindSnapshotSelector(session), + useTrajectory: bindSnapshotSelector(trajectory), loadOlder: () => Promise.resolve(false), } } @@ -136,11 +157,6 @@ function standaloneDuration(): Pick< } } -function fakeSession(nodes: ConversationSnapshot['nodes']) { - const store = createSnapshotStore(historySnapshot(nodes)) - return { store, useSession: bindSnapshotSelector(store) } -} - /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ function emptySessions() { const store = createSnapshotStore( @@ -149,50 +165,104 @@ function emptySessions() { } function emptyWorkspaces() { - const store = createSnapshotStore({ - items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, - recentWorkspaceId: undefined, + const store = createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) return bindSnapshotSelector(store) } +function emptyProjection>( + key: Key, +): SessionProjectionMap[Key] | undefined +function emptyProjection, Selected>( + key: Key, + selector: (value: SessionProjectionMap[Key] | undefined) => Selected, + eq?: (left: Selected, right: Selected) => boolean, +): Selected +function emptyProjection, Selected>( + _key: Key, + selector?: (value: SessionProjectionMap[Key] | undefined) => Selected, +): SessionProjectionMap[Key] | Selected | undefined { + return selector === undefined ? undefined : selector(undefined) +} + +const useProjection: UseProjection = emptyProjection + +type StandaloneBaseProps = Omit< + ComponentProps, + 'useSession' | 'useTrajectory' | 'useDuration' | 'loadOlder' | 'setActualDuration' +> + /** Standalone view props: the session-scope standard kit the outlet would bake. */ function standaloneProps( - nodes: ConversationSnapshot['nodes'], -): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } { + nodes: LegacyConversationSlice['nodes'], +): StandaloneBaseProps { + const trajectory = historySnapshot(nodes) + const input = createSnapshotStore({ + draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], + }) + const inputActions: InputActions = { + setDraft: () => {}, + addImages: () => false, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + } return { sessionId: SID, - useSession: fakeSession(nodes).useSession, + useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)), useSessions: emptySessions(), + useSessionPendingInteraction: bindSnapshotSelector( + createSnapshotStore(new Map()), + ), useWorkspaces: emptyWorkspaces(), - useProjection: (() => undefined) as never, + useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot(trajectory))), + useInput: bindSnapshotSelector(input), + inputActions, + useProjection, + viewRequest: null, + openView: () => {}, + completeViewRequest: () => {}, // The locale seat the outlet would inject for the declared namespace. t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key, - } as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } + } +} + +type ConversationTargetSources = { + [Target in Extract]: + ObservableSnapshot } /** Real-stack bench: root Context + real SlotRegistry ring + the plugin fiber. */ async function bench(snapshot = historySnapshot(NODES)) { - const ctx = new Context() - const slots = new SlotRegistry(ctx) + const runtime = await SlotTestRuntime.create() + runtimes.push(runtime) + const ctx = runtime.ctx + const slots = runtime.slots const loadOlder = vi.fn(() => Promise.resolve()) - const sessionStore = createSnapshotStore(snapshot) - const session = { - getSnapshot: () => sessionStore.getSnapshot(), - subscribe: (listener: () => void) => sessionStore.subscribe(listener), - loadOlder, - } - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() - ctx.provide('sessions', { - binding: () => ({ session }), + await runtime.sessions.add({ + id: SID, + snapshot: { blank: false }, + session: { loadOlder }, }) - sessionSnapshots.set(slots, sessionStore) + const trajectoryStore = createSnapshotStore(snapshot) + const conversationStore = createSnapshotStore(conversationSnapshot(snapshot)) + const uiConversation = new UiConversation(ctx, runtime.sessions) + const { events, views } = uiConversation + const targetSources: ConversationTargetSources = { + chat: createSnapshotStore(undefined), + trajectory: trajectoryStore, + } + const binding: ConversationBinding = { + snapshot: conversationStore, + target: target => targetSources[target], + } + vi.spyOn(uiConversation, 'binding').mockReturnValue(binding) // The conversation entry's role: declare the ring, then seed the chat entry. - slots.register({ - name: 'root', - children: { 'conversation.view': { kind: 'list', scope: 'session' } }, - }, (_p: { renderSlot?: unknown }) => null) + await runtime.root.declare( + { 'conversation.view': { kind: 'list', scope: 'session' } }, + (_p: { renderSlot?: unknown }) => null, + ) const chatBody = vi.fn(() =>
) slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) @@ -202,10 +272,15 @@ async function bench(snapshot = historySnapshot(NODES)) { ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - ctx.plugin({ inject: [...localeInject], apply: localeApply }) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - return { ctx, slots, fiber, loadOlder, sessionStore } + await runtime.mount({ inject: [...localeInject], apply: localeApply }) + const provide = vi.spyOn(ctx.uiSession, 'provide') + const feature = await runtime.mount({ inject: [...inject], apply }) + const sourceDescriptor = provide.mock.calls[0]?.[0] + if (sourceDescriptor === undefined) throw new Error('ui-trajectory did not provide its standard source') + return { + runtime, ctx, slots, feature, loadOlder, trajectoryStore, conversationStore, + events, views, sourceDescriptor, + } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ @@ -214,28 +289,63 @@ function tabsOf(slots: SlotRegistry): ViewTab[] { .map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! })) } +type ConvViewOwner = Pick + +function isConvViewOwner(owner: object): owner is ConvViewOwner { + return 'viewRequest' in owner + && 'openView' in owner && typeof owner.openView === 'function' + && 'completeViewRequest' in owner && typeof owner.completeViewRequest === 'function' +} + /** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */ -function mount(slots: SlotRegistry, nodes: ConversationSnapshot['nodes'] = NODES) { - const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes)) - const useSession = bindSnapshotSelector(sessionSnapshot) - const chat = createChatStore().create() - const views = { - list: () => tabsOf(slots), - subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), - version: () => slots.getVersion('conversation.view'), - } - const useInput = bindSnapshotSelector(createSnapshotStore({ +function mount(fixture: Awaited>) { + const { runtime, slots, trajectoryStore, conversationStore } = fixture + const session = runtime.sessions.binding(SID)?.session + if (session === undefined) throw new Error('trajectory fixture session is unavailable') + const useSession = bindSnapshotSelector(session) + const useTrajectory = bindSnapshotSelector(trajectoryStore) + const useConversation = bindSnapshotSelector(conversationStore) + const useChat = bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)) + const useSessions = emptySessions() + const useSessionPendingInteraction = bindSnapshotSelector( + createSnapshotStore(new Map()), + ) + const useWorkspaces = emptyWorkspaces() + const conversation = createConversationStore().create() + const useConversationViews = bindSnapshotSelector( + createSnapshotStore(tabsOf(slots)), + ) + const useInput = bindSnapshotSelector(createSnapshotStore({ draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], - })) as never - const inputActions = { - setDraft: vi.fn(), addImages: vi.fn(), removeImage: vi.fn(), pruneImages: vi.fn(), submit: vi.fn(), + })) + const inputActions: InputActions = { + setDraft: vi.fn(), + addImages: vi.fn(() => false), + removeImage: vi.fn(), + pruneImages: vi.fn(), + submit: vi.fn(), + } + const standardProps = { + sessionId: SID, + useSession, + useTrajectory, + useChat, + useConversation, + useConversationViews, + useSessions, + useSessionPendingInteraction, + useWorkspaces, + useProjection, + useInput, + inputActions, } // Minimal outlet twin: resolve the ring entry by the `only` filter and // render it with the session standard kit (what SlotOutlet does for a // list-kind session slot, minus machinery). - const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => { + const renderSlot: ConversationSessionProps['renderSlot'] = (key, owner, opts): ReactNode => { const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only) if (entry === undefined) return null + if (!isConvViewOwner(owner)) throw new Error('trajectory fixture expected Conversation view owner props') const View = entry.component as FC const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined const injected = injectEntry === undefined @@ -252,46 +362,32 @@ function mount(slots: SlotRegistry, nodes: ConversationSnapshot['nodes'] = NODES } })() : injected + const viewProps: ConvViewProps = { ...owner, ...standardProps } return ( ) - }) as unknown as ConversationSessionProps['renderSlot'] + } return render( <> children(SID)} - useSession={useSession} - useSessions={emptySessions()} - useWorkspaces={emptyWorkspaces()} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} + {...standardProps} + SessionProvider={({ children }) => children} + useStore={bindSnapshotSelector(conversation)} + actions={conversation.actions} renderSlot={() => null} - views={views} - useInput={useInput} - inputActions={inputActions} open={vi.fn()} t={tConversation} /> children(SID)} - useSession={useSession} - useSessions={emptySessions()} - useWorkspaces={emptyWorkspaces()} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} + {...standardProps} + SessionProvider={({ children }) => children} + useStore={bindSnapshotSelector(conversation)} + actions={conversation.actions} renderSlot={renderSlot} - views={views} - releaseSessionImages={vi.fn()} - useInput={useInput} - inputActions={inputActions} bindDraftMirror={() => () => {}} /> , @@ -309,16 +405,36 @@ describe('plugin registration', () => { it('fiber disposal removes the tab and leaves chat standing', async () => { const b = await bench() - const events = b.ctx.get('conversationEvents') as ConversationEventRegistry - const views = b.ctx.get('conversationViews') as ConversationViewRegistry - expect(events.entries().length).toBeGreaterThan(0) - expect(views.entries()).toHaveLength(1) + expect(b.events.entries().length).toBeGreaterThan(0) + expect(b.views.entries()).toHaveLength(1) - await b.fiber.dispose() + await b.feature.dispose() expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat']) - expect(events.entries()).toEqual([]) - expect(views.entries()).toEqual([]) + expect(b.events.entries()).toEqual([]) + expect(b.views.entries()).toEqual([]) + }) + + it('keeps one total standard source for a Session binding', async () => { + const b = await bench() + const binding = b.runtime.sessions.binding(SID) + if (binding === undefined) throw new Error('Trajectory source test Session binding is unavailable') + const resolveSource = (owner: SessionBinding): ObservableSnapshot => { + const contribution = b.sourceDescriptor.resolve(owner) as { + hooks: { trajectory: ObservableSnapshot } + } + return contribution.hooks.trajectory + } + const source = b.runtime.ctx.uiSession.adapter.resolve(SID)!.hooks.trajectory as + ObservableSnapshot + + expect(resolveSource(binding)).toBe(source) + expect(resolveSource(binding)).toBe(source) + const optionalTrajectory = b.trajectoryStore as unknown as { + set(value: TrajectorySnapshot | undefined): void + } + optionalTrajectory.set(undefined) + expect(source.getSnapshot()).toBe(EMPTY_TRAJECTORY_SNAPSHOT) }) it('shares one browser-wide duration preference across session injections', async () => { @@ -330,6 +446,7 @@ describe('plugin registration', () => { sessionId: SessionId, ) => TrajectoryViewInjected const first = injectEntry(SID) + await b.runtime.sessions.add({ id: 's2' }, { current: false }) const second = injectEntry('s2' as SessionId) expect(second.hooks.duration).toBe(first.hooks.duration) @@ -351,7 +468,7 @@ describe('plugin registration', () => { expect(await injected.loadOlder()).toBe(false) b.loadOlder.mockImplementationOnce(async () => { - b.sessionStore.set(historySnapshot([...NODES])) + b.trajectoryStore.set(historySnapshot([...NODES])) }) expect(await injected.loadOlder()).toBe(true) }) @@ -360,7 +477,7 @@ describe('plugin registration', () => { describe('tab switching in ConversationRoot', () => { it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => { const b = await bench() - const view = mount(b.slots) + const view = mount(b) expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory']) @@ -394,7 +511,7 @@ describe('tab switching in ConversationRoot', () => { it('opens a local record inspector and switches payload tabs without opening chat details', async () => { const b = await bench() - mount(b.slots) + mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' }) @@ -408,7 +525,7 @@ describe('tab switching in ConversationRoot', () => { }) it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => { - const nodes = [ + const nodes: LegacyConversationSlice['nodes'] = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, @@ -419,7 +536,7 @@ describe('tab switching in ConversationRoot', () => { kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'after' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] const compaction: RequestView = { purpose: 'compaction', startSeq: 3, @@ -431,7 +548,7 @@ describe('tab switching in ConversationRoot', () => { summary: [{ type: 'text', text: 'standalone summary' }], } const b = await bench(historySnapshot(nodes, { requests: [compaction] })) - const view = mount(b.slots, nodes) + const view = mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.getByText('Between turns')).toBeTruthy() @@ -443,7 +560,7 @@ describe('tab switching in ConversationRoot', () => { }) it('activates only the selected standalone compaction section', async () => { - const nodes = [ + const nodes: LegacyConversationSlice['nodes'] = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, @@ -459,7 +576,7 @@ describe('tab switching in ConversationRoot', () => { kind: 'assistant', seq: 10, time: 10_000, turn: 3, step: 1, blocks: [{ kind: 'text', text: 'after second compaction' }], }, - ] as unknown as ConversationSnapshot['nodes'] + ] const compactions: RequestView[] = [ { purpose: 'compaction', @@ -483,7 +600,7 @@ describe('tab switching in ConversationRoot', () => { }, ] const b = await bench(historySnapshot(nodes, { requests: compactions })) - mount(b.slots, nodes) + mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) const firstRequest = screen.getByRole('button', { name: 'Request #2 · Compaction' }) @@ -508,7 +625,7 @@ describe('tab switching in ConversationRoot', () => { it('dragging the overview focuses overlapping records without filtering the ledger', async () => { const b = await bench() - mount(b.slots) + mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ @@ -540,7 +657,7 @@ describe('tab switching in ConversationRoot', () => { it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => { const b = await bench() - const view = mount(b.slots) + const view = mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ @@ -578,7 +695,7 @@ describe('tab switching in ConversationRoot', () => { it('empty window keeps the toolbar and reports no timing data', async () => { const b = await bench(historySnapshot([])) - mount(b.slots) + mount(b) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy() @@ -1170,20 +1287,21 @@ describe('TrajectoryView state', () => { it('keeps ledger and timeline selection on the same event after prepend', () => { - const older = { + const older: LegacyConversationSlice['nodes'][number] = { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'older prompt' }], source: null, - } as unknown as ConversationSnapshot['nodes'][number] - const current = { + } + const current: LegacyConversationSlice['nodes'][number] = { kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'selected current response' }], - } as unknown as ConversationSnapshot['nodes'][number] + } const store = createSnapshotStore(historySnapshot([current])) const view = render( Promise.resolve(false))} />, ) diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index 028c60be8e..f18a03de31 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -14,15 +14,30 @@ { "path": "../locale" }, + { + "path": "../store" + }, { "path": "../ui-conversation" }, { - "path": "../runtime" + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../api/session-controller/tsconfig.client.json" }, { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, { "path": "../../core/tools" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 884c005d82..788b8b6e2c 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -31,9 +31,16 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-client-ui-conversation/client" + ], "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } @@ -49,15 +56,21 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^" + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-user-questions": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -66,10 +79,13 @@ "@types/react": "~18.3.1", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "react": "^18.2.0", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^" + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index b2085cc151..03bf89757e 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -6,9 +6,10 @@ import { IconEditOutline16, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import { - PendingQuestion, planReviewOf, + planReviewOf, type QuestionAnswer, type QuestionComposerProps, } from './contract/slots.ts' +import type { PendingQuestion } from './contract/slots.ts' import { PlanReviewPanel } from './PlanReviewPanel.tsx' import css from './QuestionComposer.module.css' @@ -114,9 +115,7 @@ function AnswerField(props: AnswerFieldProps) { * @returns The question flow, or the intent's own surface, for this request. */ export function QuestionComposer(props: QuestionComposerProps) { - // Domain-face mint rides the carrier's stable identity (never minted in a - // select/render dispatch — per-dispatch minting would churn memo identity). - const question = useMemo(() => new PendingQuestion(props.matched), [props.matched]) + const question = props.matched const review = useMemo(() => planReviewOf(question.questions), [question]) return review === undefined ? diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index c37351578e..0a65ef79eb 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -1,26 +1,24 @@ -/** - * Question-composer slot contract: the registrant-side props composition for - * the conversation-owned `conversation.composer` slot, plus the question - * domain face over the runtime's carrier object. The carrier (PendingWait) - * owns envelope transport only; the question protocol — answer value shape, - * cancelled error encoding, receipt checks — lives HERE, with the package - * that consumes it. - */ +/** Question composer props and one pending Remote waterfall response. */ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer' -// entry) into every program that sees this contract, so PropsRuntime resolves. -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { QuestionResponsePayload } from '@deepseek-ai/dsh-api-remotes/client' +// The client module declares the conversation.composer SlotMap entry required by PropsRuntime. +import { settlePendingComposer } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + AskUserQuestionAnswer, AskUserQuestionItem, +} from '@deepseek-ai/dsh-user-questions' -/** The pending question carrier the owner dispatches into the composer slot. */ -export type QuestionWait = PendingWait<'question'> +declare module '@deepseek-ai/dsh-client-ui-session/client' { + interface SessionPendingInteractionMap { + /** Pending question or plan-review request. */ + question: PendingQuestion + } +} /** One structured answer batch covering every question of the request. */ -export type QuestionAnswer = QuestionResponsePayload['answer'] +export type QuestionAnswer = AskUserQuestionAnswer -/** One question of the request, as the carrier payload carries it. */ -type QuestionItem = QuestionWait['payload']['questions'][number] +/** One question of the request. */ +type QuestionItem = AskUserQuestionItem /** One option the asker offered on a question. */ type QuestionOption = NonNullable[number] @@ -84,54 +82,121 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u } } -/** - * Question domain face over the carrier: render identity and questions - * transparently forwarded; answer/cancel own the wire encoding (the success - * fields and the cancelled error) and turn a rejected carrier receipt into a - * thrown error. Components mint one per carrier via useMemo (never inside a - * select — a per-dispatch mint would churn identity and break memoization). - */ +let nextQuestionKey = 0 + +/** Create a wire-preserved user-question rejection. */ +function questionError(message: string, code: 'ASK_ABORTED' | 'ASK_CANCELLED'): Error { + const error = new Error(message) as Error & { code: string } + error.name = 'UserQuestionError' + error.code = code + return error +} + +/** One answerable Client presentation of a pending Host waterfall. */ export class PendingQuestion { + /** Presentation discriminator used by Session pending-interaction consumers. */ + readonly kind: 'question' | 'plan-review' + /** Opaque render identity and local-draft remount axis. */ + readonly key: string + /** The request's question list. */ + readonly questions: readonly AskUserQuestionItem[] + /** Result returned by the Remote Event listener to the Host waterfall. */ + readonly result: Promise + + readonly #resolve: (answer: QuestionAnswer) => void + readonly #reject: (reason: unknown) => void + readonly #signal: AbortSignal | undefined + readonly #onAbort: (() => void) | undefined + readonly #delegated = Symbol('pending question delegated') + #settled = false + /** - * @param wait - the runtime carrier for one pending question request. + * @param sessionId - Agent/Session identity owning the scoped request. + * @param questions - complete question batch. + * @param signal - Host request and delivery lifetime. */ - constructor(private readonly wait: QuestionWait) {} - - /** Opaque render identity (React key / draft remount axis), forwarded from the carrier. */ - get key(): string { - return this.wait.key - } - - /** The request's question list, forwarded from the carrier payload. */ - get questions(): QuestionWait['payload']['questions'] { - return this.wait.payload.questions + constructor( + readonly sessionId: SessionId, + questions: readonly AskUserQuestionItem[], + signal?: AbortSignal, + ) { + nextQuestionKey += 1 + this.key = `question:${String(nextQuestionKey)}` + this.questions = questions + this.kind = planReviewOf(questions) === undefined ? 'question' : 'plan-review' + const completion = Promise.withResolvers() + this.result = completion.promise + this.#resolve = completion.resolve + this.#reject = completion.reject + this.#signal = signal + if (signal === undefined) { + this.#onAbort = undefined + return + } + const onAbort = (): void => { + this.abort(questionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')) + } + this.#onAbort = onAbort + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() } /** - * Deliver the whole answer batch; a rejected carrier receipt throws. + * Resolve the Host waterfall with the whole answer batch. * @param answer - complete structured answer batch. */ - async answer(answer: QuestionAnswer): Promise { - const receipt = await this.wait.respond({ - ok: true, value: { sessionId: this.wait.sessionId, answer }, - }) - if (!receipt.accepted) { - throw new Error(`question response rejected: ${receipt.reason}`) - } + answer(answer: QuestionAnswer): Promise { + return settlePendingComposer(() => { + this.finish(() => { this.#resolve(answer) }) + }, 'pending question settlement failed') } - /** Reject the whole wait (the host resolves the tool call as cancelled); a rejected receipt throws. */ - async cancel(): Promise { - const receipt = await this.wait.respond({ - ok: false, - error: { code: 'cancelled', message: 'the user closed this question request', details: {} }, - }) - if (!receipt.accepted) { - throw new Error(`question cancellation rejected: ${receipt.reason}`) + /** Delegate an unanswered request to the next waterfall listener. */ + delegate(): void { + if (this.#settled) return + this.finish(() => { this.#reject(this.#delegated) }) + } + + /** + * Test whether a rejection requests waterfall delegation. + * @param reason - rejection received from {@link PendingQuestion.result}. + * @returns whether {@link PendingQuestion.delegate} produced it. + */ + isDelegation(reason: unknown): boolean { + return reason === this.#delegated + } + + /** Reject the Host waterfall because the user closed the question. */ + cancel(): Promise { + return settlePendingComposer(() => { + this.finish(() => { + this.#reject(questionError('the user cancelled ask_user_question', 'ASK_CANCELLED')) + }) + }, 'pending question cancellation failed') + } + + /** + * End an unanswered presentation when its transport, scope, or plugin lifetime ends. + * @param reason - rejection exposed to the waiting Remote Event listener. + */ + abort(reason: unknown): void { + if (this.#settled) return + this.finish(() => { this.#reject(reason) }) + } + + private finish(settle: () => void): void { + if (this.#settled) throw new Error(`pending question ${this.key} is already settled`) + this.#settled = true + if (this.#signal !== undefined && this.#onAbort !== undefined) { + this.#signal.removeEventListener('abort', this.#onAbort) } + settle() } } +/** Pending value returned by the composer-chain selector. */ +export type QuestionWait = PendingQuestion + /** * Full component props: the framework runtime share (chain currency + * session/global standard kit) plus the chain `matched` share — the entry's diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 20f3c37f12..5344c80ed7 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -12,17 +12,21 @@ * separate chain entry per shape would race the same carrier, so the shape * choice lives inside this entry — see QuestionComposer. */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { PendingInteractionPublisher } from '@deepseek-ai/dsh-client-ui-session/client' +import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { QuestionWait } from './contract/slots.ts' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import { PendingQuestion } from './contract/slots.ts' import { QuestionComposer } from './QuestionComposer.tsx' import { en, zh, type QuestionKey } from './locales.ts' -export { PendingQuestion } from './contract/slots.ts' export type { - PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait, + PendingQuestion, PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait, } from './contract/slots.ts' export type { QuestionKey } from './locales.ts' @@ -36,12 +40,41 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'question' -/** Required services: the slot registry and the question composer's copy. */ -export const inject = ['slots', 'locale'] +type QuestionListener = TypertClientEventListener<'user-questions/request'> +type ClientQuestionRequest = Parameters[0] +type ClientQuestionNext = Parameters[1] +type ClientQuestionAnswer = Awaited> -/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */ -function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null { - return interactions.find((i): i is QuestionWait => i.kind === 'question') ?? null +/** Required services: Agent scopes, Remote Events, Session UI, Slot registry, and copy. */ +export const inject = ['sessions', 'remote', 'uiSession', 'slots', 'locale'] + +/** Present one request until the user answers, cancels, or its lifetime ends. */ +async function answerQuestion( + ctx: ClientContext, + owner: ClientContext, + request: ClientQuestionRequest, + next: ClientQuestionNext, + registerPendingInteraction: PendingInteractionPublisher, +): Promise { + const sessionId = ctx.sessions.scopeOf(owner) + if (sessionId === undefined) return next() + const pending = new PendingQuestion(sessionId, request.questions, request.signal) + const completed = Promise.withResolvers() + const remove = registerPendingInteraction(pending, async () => { + pending.delegate() + await completed.promise + }) + try { + try { + return await pending.result + } catch (error) { + if (pending.isDelegation(error)) return await next() + throw error + } + } finally { + remove() + completed.resolve() + } } /** @@ -52,9 +85,19 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries') - + const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( + pending => pending.kind === 'plan-review' ? 2 : 1, + ) ctx.slots.inject('conversation.composer', () => ctx.slots.register( - { name: 'conversation.composer', select: selectQuestion, locale: NS }, + { + name: 'conversation.composer', + select: ({ pendingInteraction }: ComposerChainProps): PendingQuestion | null => + pendingInteraction instanceof PendingQuestion ? pendingInteraction : null, + locale: NS, + }, QuestionComposer, )) + ctx.remote.$on('user-questions/request', function (request, next) { + return answerQuestion(ctx, this, request, next, registerPendingInteraction) + }) } diff --git a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts index 121df27298..881e6fa193 100644 --- a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts @@ -1,74 +1,295 @@ -/** - * apply wiring on a real cordis Context + SlotRegistry: QuestionComposer - * registered as the `question` entry of the conversation-declared composer - * slot with ZERO business face (data and verbs ride the dispatched carrier), - * declaration-aware activation, and fiber-teardown unregistration. Component and - * domain-face behavior is covered props-direct in question-composer.spec.tsx; - * no renderer machinery here. - */ +/** Scoped Remote Event wiring for the browser question consumer. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { describe, expect, it, vi } from 'vitest' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { QuestionComposer } from '../src/client/QuestionComposer.tsx' +import { PendingQuestion } from '../src/client/contract/slots.ts' import { apply, inject } from '../src/client/index.ts' -async function bench() { +const SESSION_ID = 'session-question' as SessionId +const SESSION_SCOPE = Symbol('question-session-scope') +const QUESTIONS = [{ id: 'mode', question: 'Choose a mode' }] as const +const PLAN_QUESTIONS: PendingQuestion['questions'] = [{ + id: 'plan', + question: 'Approve this plan?', + detail: '# Plan', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], + intent: { kind: 'plan-review' as const, approve: 'Approve' }, +}] +const ANSWER = { answers: [{ id: 'mode', selected: ['Fast'] }] } + +type QuestionRequest = { + questions: PendingQuestion['questions'] + signal?: AbortSignal +} +type QuestionAnswer = typeof ANSWER +type QuestionNext = () => Promise +type QuestionListener = ( + this: Context, + request: QuestionRequest, + next: QuestionNext, +) => Promise + +async function bench(declare = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const slots = ctx.get('slots') as SlotRegistry - // The composer slot exists only while its declaring entry is live. - slots.register( - { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, - () => null, - ) - ctx.provide('locale', new LocaleRuntime(ctx)) - return { ctx, slots } + if (declare) { + slots.register( + { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, + () => null, + ) + } + const locale = new LocaleRuntime(ctx) + ctx.provide('locale', locale) + const agent = ctx.extend({ [SESSION_SCOPE]: SESSION_ID }) + const scopeOf = vi.fn((candidate: Context) => ( + candidate as Context & { [SESSION_SCOPE]?: SessionId } + )[SESSION_SCOPE]) + ctx.provide('sessions', { scopeOf } as never) + const pending = new Map Promise>() + const registerPendingInteraction = vi.fn((_precedence: (value: PendingQuestion) => number) => ( + value: PendingQuestion, + delegate: () => Promise, + ) => { + _precedence(value) + pending.set(value, delegate) + return () => { pending.delete(value) } + }) + ctx.provide('uiSession', { registerPendingInteraction } as never) + let listener: QuestionListener | undefined + const on = vi.fn((event: string, value: QuestionListener) => { + expect(event).toBe('user-questions/request') + listener = value + return () => { listener = undefined } + }) + ctx.provide('remote', { $on: on } as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const invoke = ( + owner: Context, + request: QuestionRequest, + next: QuestionNext, + ): Promise => { + if (listener === undefined) throw new Error('question listener was not installed') + return listener.call(owner, request, next) + } + return { + ctx, + slots, + locale, + agent, + scopeOf, + pending: { getSnapshot: () => [...pending.keys()] }, + registerPendingInteraction, + on, + fiber, + invoke, + async releasePending() { + const delegates = [...pending.values()] + pending.clear() + await Promise.allSettled(delegates.map(delegate => delegate())) + }, + } } describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['sessions', 'remote', 'uiSession', 'slots', 'locale']) }) - it('waits until a live entry declares the composer slot', async () => { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - ctx.provide('locale', new LocaleRuntime(ctx)) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(ctx.slots.entries('conversation.composer')).toHaveLength(0) - ctx.slots.register( - { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, - () => null, - ) + it('installs the Remote Event listener and delegates an unscoped request', async () => { + const b = await bench(false) + const next = vi.fn(async () => ANSWER) + + await expect(b.invoke(b.ctx, { questions: QUESTIONS }, next)).resolves.toBe(ANSWER) + + expect(b.on).toHaveBeenCalledOnce() + expect(next).toHaveBeenCalledOnce() + expect(b.slots.entries('conversation.composer')).toHaveLength(0) + expect(b.pending.getSnapshot()).toEqual([]) + }) + + it('projects a scoped request through one stable composer and returns its answer', async () => { + const b = await bench() + const next = vi.fn(async () => ANSWER) + const result = b.invoke(b.agent, { questions: QUESTIONS }, next) await Promise.resolve() - expect(ctx.slots.entries('conversation.composer')).toHaveLength(1) - }) - it('registers the question entry: routing selector, no inject face', async () => { - const { ctx, slots } = await bench() - await ctx.plugin({ inject: [...inject], apply }).await() - const entry = slots.entries('conversation.composer')[0]! + const entry = b.slots.entries('conversation.composer')[0]! expect(entry.component).toBe(QuestionComposer) - // The whole behavior surface rides the matched carrier: no business face; - // copy rides the standard locale seat. expect(entry.inject).toBeUndefined() expect(entry.locale).toBe('question') - // The selector narrows the chain currency: question wait in → that wait; none → null. - const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown - const question = { kind: 'question' } - expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question) - expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull() - expect(select({ interactions: [] })).toBeNull() + const pending = b.pending.getSnapshot()[0]! + const select = entry.select as ( + owner: { pendingInteraction: PendingQuestion | undefined }, + ) => PendingQuestion | null + expect(select({ pendingInteraction: undefined })).toBeNull() + expect(select({ pendingInteraction: pending })).toBe(pending) + expect(pending).toMatchObject({ kind: 'question', sessionId: SESSION_ID, questions: QUESTIONS }) + + await pending.answer(ANSWER) + await expect(result).resolves.toBe(ANSWER) + expect(next).not.toHaveBeenCalled() + expect(b.pending.getSnapshot()).toEqual([]) + expect(b.slots.entries('conversation.composer')).toHaveLength(1) }) - it('teardown unregisters the slot entry', async () => { - const { ctx, slots } = await bench() - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(slots.entries('conversation.composer')).toHaveLength(1) - await fiber.dispose() - expect(slots.entries('conversation.composer')).toHaveLength(0) + it('preserves ASK_CANCELLED as a rejected waterfall result', async () => { + const b = await bench() + const result = b.invoke(b.agent, { questions: QUESTIONS }, async () => ANSWER) + await Promise.resolve() + const pending = b.pending.getSnapshot()[0]! + const rejection = expect(result).rejects.toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + message: 'the user cancelled ask_user_question', + }) + + await pending.cancel() + await rejection + expect(b.pending.getSnapshot()).toEqual([]) + expect(b.slots.entries('conversation.composer')).toHaveLength(1) + }) + + it('publishes a plan-review request with its distinct interaction kind', async () => { + const b = await bench() + const result = b.invoke(b.agent, { questions: PLAN_QUESTIONS }, async () => ANSWER) + await Promise.resolve() + const pending = b.pending.getSnapshot()[0]! + + expect(pending.kind).toBe('plan-review') + await pending.answer(ANSWER) + await expect(result).resolves.toBe(ANSWER) + expect(b.pending.getSnapshot()).toEqual([]) + }) + + it('removes a cancelled request while preserving the stable composer', async () => { + const b = await bench() + const controller = new AbortController() + const result = b.invoke(b.agent, { questions: QUESTIONS, signal: controller.signal }, async () => ANSWER) + await Promise.resolve() + expect(b.pending.getSnapshot()).toHaveLength(1) + + controller.abort() + + await expect(result).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + expect(b.pending.getSnapshot()).toEqual([]) + expect(b.slots.entries('conversation.composer')).toHaveLength(1) + }) + + it('delegates an active request when its interaction domain unloads', async () => { + const b = await bench() + const next = vi.fn(async () => ANSWER) + const result = b.invoke(b.agent, { questions: QUESTIONS }, next) + await Promise.resolve() + expect(b.pending.getSnapshot()).toHaveLength(1) + + await b.releasePending() + + await expect(result).resolves.toBe(ANSWER) + expect(next).toHaveBeenCalledOnce() + expect(b.pending.getSnapshot()).toEqual([]) + }) + + it('removes the stable composer with the plugin lifetime', async () => { + const b = await bench() + expect(b.slots.entries('conversation.composer')).toHaveLength(1) + + await b.fiber.dispose() + + expect(b.slots.entries('conversation.composer')).toHaveLength(0) + }) +}) + +describe('PendingQuestion', () => { + it('preserves an already-aborted request signal as ASK_ABORTED', async () => { + const lifetime = new AbortController() + lifetime.abort() + const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal) + + await expect(pending.result).rejects.toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_ABORTED', + message: 'ask_user_question was aborted before the user answered', + }) + }) + + it('rejects on later request cancellation and removes the listener after settlement', async () => { + const lifetime = new AbortController() + const remove = vi.spyOn(lifetime.signal, 'removeEventListener') + const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal) + const rejected = expect(pending.result).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + + lifetime.abort() + + await rejected + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + }) + + it('ignores a lifecycle abort after the answer already settled', async () => { + const lifetime = new AbortController() + const pending = new PendingQuestion(SESSION_ID, QUESTIONS, lifetime.signal) + + await pending.answer(ANSWER) + await expect(pending.result).resolves.toBe(ANSWER) + pending.abort(new Error('late disposal')) + pending.delegate() + }) + + it('rejects an unanswered request with its caller-owned lifecycle reason', async () => { + const pending = new PendingQuestion(SESSION_ID, QUESTIONS) + const reason = new Error('scope released') + const rejected = expect(pending.result).rejects.toBe(reason) + + pending.abort(reason) + + await rejected + }) + + it('wraps a non-Error answer settlement failure with its cause', async () => { + const failure = 'resolve failed' + const completion = Promise.withResolvers() + const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({ + promise: completion.promise, + resolve: () => { throw failure }, + reject: completion.reject, + })) + const pending = new PendingQuestion(SESSION_ID, QUESTIONS) + withResolvers.mockRestore() + + const settlement = await pending.answer(ANSWER).catch((error: unknown) => error) + + expect(settlement).toBeInstanceOf(Error) + expect(settlement).toMatchObject({ + message: 'pending question settlement failed', + cause: failure, + }) + completion.resolve(ANSWER) + await expect(pending.result).resolves.toBe(ANSWER) + }) + + it('wraps a non-Error cancellation settlement failure with its cause', async () => { + const failure = 'reject failed' + const completion = Promise.withResolvers() + const withResolvers = vi.spyOn(Promise, 'withResolvers').mockImplementationOnce(() => ({ + promise: completion.promise, + resolve: completion.resolve as (value: T | PromiseLike) => void, + reject: () => { throw failure }, + })) + const pending = new PendingQuestion(SESSION_ID, QUESTIONS) + withResolvers.mockRestore() + + const settlement = await pending.cancel().catch((error: unknown) => error) + + expect(settlement).toBeInstanceOf(Error) + expect(settlement).toMatchObject({ + message: 'pending question cancellation failed', + cause: failure, + }) + completion.resolve(ANSWER) + await expect(pending.result).resolves.toBe(ANSWER) }) }) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 4479a50fed..4faba04c00 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -1,14 +1,10 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { - ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait, +} from '../src/client/contract/slots.ts' import { QuestionComposer } from '../src/client/QuestionComposer.tsx' import { en, zh } from '../src/client/locales.ts' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -21,23 +17,109 @@ const SID = 's1' as SessionId const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => (key => dict[key] ?? common[key] ?? key) +type SessionState = Parameters[0]>[0] +type ConversationState = Parameters[0]>[0] +type ChatState = Parameters[0]>[0] +type TrajectoryState = Parameters[0]>[0] +type InputState = Parameters[0]>[0] +type AttentionState = Parameters[0]>[0] + +const sessionState: SessionState = { + sessionId: SID, + queue: [], + running: false, + subagent: null, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, +} +const sessionList = { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, blank: false, updatedAt: 0 } }, + current: SID, + phase: 'ready' as const, + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, +} +const attentionState: AttentionState = new Map() +const workspaceState = { + items: [], + archivedSessionIds: [], + state: 'idle' as const, + phase: 'ready' as const, + error: null, +} +const conversationState: ConversationState = { + views: { get: () => undefined }, + activeTargets: new Set(), +} +const emptyKeys: readonly string[] = [] +const chatState: ChatState = { + order: emptyKeys, + nodes: { get: () => undefined, values: () => [] }, + locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys }, + timeline: { turnOrder: [], turns: new Map() }, + legacy: { + nodes: [], + turnTimings: new Map(), + turnEnds: new Map(), + partial: null, + runningCalls: [], + }, +} +const trajectoryState: TrajectoryState = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} +const inputState: InputState = { + draft: '', + imageIds: [], + draftRev: 0, + phase: 'plain', + occurrences: [], + queue: [], +} + /** Framework standard-kit stubs: the panel consumes only the locale seat. */ -const kit = { +const kit: Omit = { sessionId: SID, session: undefined, - useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useProjection: (() => undefined) as never, - useInput: (() => { throw new Error('unused') }) as never, - inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, + pendingInteraction: undefined, + useSession: selector => selector(sessionState), + useSessions: selector => selector(sessionList), + useSessionPendingInteraction: selector => selector(attentionState), + useWorkspaces: selector => selector(workspaceState), + useConversation: selector => selector(conversationState), + useChat: selector => selector(chatState), + useTrajectory: selector => selector(trajectoryState), + useProjection: (() => undefined), + useInput: selector => selector(inputState), + inputActions: { + setDraft: () => { throw new Error('unused') }, + addImages: () => { throw new Error('unused') }, + removeImage: () => { throw new Error('unused') }, + pruneImages: () => { throw new Error('unused') }, + submit: () => { throw new Error('unused') }, + }, t: seatOver(zh, commonZh), } const PLAN = '# Ship the picker\n\n- read the store\n- render the rows\n' /** The plan-mode request shape: one question, the plan as detail, approve named. */ -const questions = (): QuestionWait['payload']['questions'] => [{ +const questions = (): QuestionWait['questions'] => [{ id: 'plan-review', header: 'Plan review', question: 'Approve this plan and leave plan mode?', @@ -49,21 +131,16 @@ const questions = (): QuestionWait['payload']['questions'] => [{ intent: { kind: 'plan-review', approve: 'Approve' }, }] -/** Carrier fixture over a scripted respond carrier. */ -function wait( - payload: QuestionWait['payload'] = { questions: questions() }, - respond = vi.fn(() => Promise.resolve({ accepted: true })), -) { - return { carrier: new PendingWait('question', RpcId('q-1'), SID, payload, respond), respond } +/** Pending waterfall fixture with observable Client response methods. */ +function wait(items: QuestionWait['questions'] = questions()) { + const carrier = new PendingQuestion(SID, items) + const answer = vi.spyOn(carrier, 'answer') + const cancel = vi.spyOn(carrier, 'cancel') + void carrier.result.catch(() => {}) + return { carrier, answer, cancel } } -/** The client-response envelope respond must have received for a decision. */ -function decidedEnvelope(label: string) { - return { - type: 'client-response', rpcId: RpcId('q-1'), - result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } }, - } -} +const decision = (label: string) => ({ answers: [{ id: 'plan-review', selected: [label] }] }) describe('planReviewOf', () => { it('narrows a plan-review request to its decision, options included', () => { @@ -110,9 +187,9 @@ describe('planReviewOf', () => { describe('PlanReviewPanel', () => { it('renders the plan under a review strip, with none of the quiz affordances', () => { const { carrier } = wait() - render() + render() - expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy() + expect(document.querySelector('[data-plan-review-key]')?.getAttribute('data-plan-review-key')).toBe(carrier.key) expect(screen.getByText(zh['plan.header'])).toBeTruthy() // The plan renders as markdown, so its heading is a heading. expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy() @@ -128,69 +205,61 @@ describe('PlanReviewPanel', () => { }) it('answers with the asker\'s approve label and keeps its description as the tooltip', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() const approve = screen.getByRole('button', { name: zh['plan.approve'] }) expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.') fireEvent.click(approve) - expect(respond).toHaveBeenCalledWith(decidedEnvelope('Approve')) + expect(answer).toHaveBeenCalledWith(decision('Approve')) // One-shot: every action locks until the host's resolved frame lands. expect(approve.hasAttribute('disabled')).toBe(true) expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true) fireEvent.click(approve) - expect(respond).toHaveBeenCalledTimes(1) + expect(answer).toHaveBeenCalledTimes(1) }) it('answers with the asker\'s decline label', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] })) - expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning')) + expect(answer).toHaveBeenCalledWith(decision('Keep planning')) }) it('dismisses the request so the composer returns for a plain message', () => { - const { carrier, respond } = wait() - render() + const { carrier, cancel } = wait() + render() fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] })) - expect(respond).toHaveBeenCalledWith({ - type: 'client-response', rpcId: RpcId('q-1'), - result: { - ok: false, - error: { code: 'cancelled', message: 'the user closed this question request', details: {} }, - }, - }) + expect(cancel).toHaveBeenCalledWith() }) it('omits the tooltip for an option carrying no description', () => { - const { carrier } = wait({ questions: [{ + const { carrier } = wait([{ ...questions()[0] as object, options: [{ label: 'Approve' }, { label: 'Keep planning' }], - }] as never }) - render() + }] as never) + render() expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false) expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false) }) it('hides the decline action when the asker offered approve alone', () => { - const { carrier } = wait({ questions: [{ + const { carrier } = wait([{ ...questions()[0] as object, options: [{ label: 'Approve' }], - }] as never }) - render() + }] as never) + render() expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull() expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy() }) it('re-arms the actions and says why when the decision does not land', async () => { - const { carrier, respond } = wait( - { questions: questions() }, - vi.fn(() => Promise.resolve({ accepted: false, reason: 'not-pending' })), - ) - render() + const { carrier, answer } = wait() + answer.mockRejectedValue(new Error('question response rejected: not-pending')) + render() fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] })) const failure = await screen.findByText('question response rejected: not-pending') @@ -198,15 +267,15 @@ describe('PlanReviewPanel', () => { // Re-armed for the retry: a lost click must not leave a dead card. expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false) fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] })) - expect(respond).toHaveBeenCalledTimes(2) + expect(answer).toHaveBeenCalledTimes(2) }) it('reports a non-Error transport failure as its stringified value', async () => { // A non-Error rejection is the case under test: a carrier can reject with // anything, and the panel must still show the user something. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercises non-Error rejections - const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone'))) - render() + const { carrier, cancel } = wait() + cancel.mockRejectedValue('socket gone') + render() fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] })) expect(await screen.findByText('socket gone')).toBeTruthy() @@ -214,7 +283,7 @@ describe('PlanReviewPanel', () => { it('carries the same decision surface in English', () => { const { carrier } = wait() - render() + render() expect(screen.getByText('Plan review')).toBeTruthy() expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy() diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 6d999d9743..a650dd6a5b 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -1,13 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { - ConversationSnapshot, SessionId, SessionListState, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' -import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts' import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx' import { en, zh } from '../src/client/locales.ts' @@ -21,23 +15,109 @@ const SID = 's1' as SessionId const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => (key => dict[key] ?? common[key] ?? key) +type SessionState = Parameters[0]>[0] +type ConversationState = Parameters[0]>[0] +type ChatState = Parameters[0]>[0] +type TrajectoryState = Parameters[0]>[0] +type InputState = Parameters[0]>[0] +type AttentionState = Parameters[0]>[0] + +const sessionState: SessionState = { + sessionId: SID, + queue: [], + running: false, + subagent: null, + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, +} +const sessionList = { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, blank: false, updatedAt: 0 } }, + current: SID, + phase: 'ready' as const, + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, +} +const attentionState: AttentionState = new Map() +const workspaceState = { + items: [], + archivedSessionIds: [], + state: 'idle' as const, + phase: 'ready' as const, + error: null, +} +const conversationState: ConversationState = { + views: { get: () => undefined }, + activeTargets: new Set(), +} +const emptyKeys: readonly string[] = [] +const chatState: ChatState = { + order: emptyKeys, + nodes: { get: () => undefined, values: () => [] }, + locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys }, + timeline: { turnOrder: [], turns: new Map() }, + legacy: { + nodes: [], + turnTimings: new Map(), + turnEnds: new Map(), + partial: null, + runningCalls: [], + }, +} +const trajectoryState: TrajectoryState = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} +const inputState: InputState = { + draft: '', + imageIds: [], + draftRev: 0, + phase: 'plain', + occurrences: [], + queue: [], +} + /** Framework standard-kit stubs: the composer consumes only the locale seat; * the composed props type mandates delivery of the rest (framework hooks are * plain stubs per the client testing discipline). */ -const kit = { +const kit: Omit = { session: undefined, sessionId: SID, - useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, - useProjection: (() => undefined) as never, - useInput: (() => { throw new Error('unused') }) as never, - inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never, + pendingInteraction: undefined, + useSession: selector => selector(sessionState), + useSessions: selector => selector(sessionList), + useSessionPendingInteraction: selector => selector(attentionState), + useWorkspaces: selector => selector(workspaceState), + useConversation: selector => selector(conversationState), + useChat: selector => selector(chatState), + useTrajectory: selector => selector(trajectoryState), + useProjection: (() => undefined), + useInput: selector => selector(inputState), + inputActions: { + setDraft: () => { throw new Error('unused') }, + addImages: () => { throw new Error('unused') }, + removeImage: () => { throw new Error('unused') }, + pruneImages: () => { throw new Error('unused') }, + submit: () => { throw new Error('unused') }, + }, // The seat's key domain is question ∪ common. t: seatOver(zh, commonZh), } -const QUESTIONS = [ +const QUESTIONS: PendingQuestion['questions'] = [ { id: 'profile', header: '偏好', question: '选择候选人类型', detail: '按当前空缺岗位的优先级选择。', @@ -55,25 +135,21 @@ const QUESTIONS = [ }, ] -/** Carrier fixture: a real PendingWait over a scripted respond carrier. */ -function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve({ accepted: true }))) { - const carrier = new PendingWait( - 'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond) - return { carrier, respond } +/** Pending waterfall fixture with observable Client response methods. */ +function wait(questions: PendingQuestion['questions'] = QUESTIONS) { + const carrier = new PendingQuestion(SID, questions) + const answer = vi.spyOn(carrier, 'answer') + const cancel = vi.spyOn(carrier, 'cancel') + void carrier.result.catch(() => {}) + return { carrier, answer, cancel } } -/** The client-response envelope respond must have received for an answer batch. */ -function answeredEnvelope(rpcId: string, answers: object[]) { - return { - type: 'client-response', rpcId: RpcId(rpcId), - result: { ok: true, value: { sessionId: SID, answer: { answers } } }, - } -} +const answerBatch = (answers: object[]) => ({ answers }) describe('QuestionComposer', () => { it('collects single, custom, and multi-select answers before one batch submit', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() expect(screen.getByText('偏好')).toBeTruthy() expect(screen.getByText('1 / 3')).toBeTruthy() @@ -85,7 +161,7 @@ describe('QuestionComposer', () => { expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true) expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false) fireEvent.keyDown(screen.getByRole('radio', { name: /工程落地型/ }), { key: 'Enter' }) - expect(respond).not.toHaveBeenCalled() + expect(answer).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) expect(screen.getByText('2 / 3')).toBeTruthy() @@ -112,7 +188,7 @@ describe('QuestionComposer', () => { fireEvent.keyDown(multiCustom, { key: 'Enter' }) // The domain face encoded the whole batch into one carrier envelope. - expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ + expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, { id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' }, @@ -121,21 +197,13 @@ describe('QuestionComposer', () => { }) it('renders plan detail through the shared assistant Markdown primitive', () => { - const carrier = new PendingWait( - 'question', - RpcId('markdown-plan'), - SID, - { - questions: [{ - id: 'plan', - question: '批准这个计划吗?', - detail: '# 实施计划\n\n- **先验证**现状\n- 修改 `QuestionComposer`', - options: [{ label: '批准' }], - }], - }, - vi.fn(), - ) - const view = render() + const { carrier } = wait([{ + id: 'plan', + question: '批准这个计划吗?', + detail: '# 实施计划\n\n- **先验证**现状\n- 修改 `QuestionComposer`', + options: [{ label: '批准' }], + }]) + const view = render() expect(screen.getByRole('heading', { level: 1, name: '实施计划' })).toBeTruthy() expect(view.container.querySelector('strong')?.textContent).toBe('先验证') @@ -144,8 +212,8 @@ describe('QuestionComposer', () => { }) it('skips individual questions without discarding earlier answers', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) @@ -154,7 +222,7 @@ describe('QuestionComposer', () => { expect(screen.getByText('3 / 3')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '跳过本题' })) - expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ + expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: ['研究潜力型'] }, { id: 'detail', selected: [] }, { id: 'signals', selected: [] }, @@ -162,8 +230,8 @@ describe('QuestionComposer', () => { }) it('keeps IME Enter inside the custom input until composition finishes', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) const custom = screen.getByPlaceholderText('输入你的答案') @@ -171,19 +239,19 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter', isComposing: true }) expect(screen.getByText('2 / 3')).toBeTruthy() - expect(respond).not.toHaveBeenCalled() + expect(answer).not.toHaveBeenCalled() fireEvent.keyDown(custom, { key: 'Enter', keyCode: 229 }) expect(screen.getByText('2 / 3')).toBeTruthy() - expect(respond).not.toHaveBeenCalled() + expect(answer).not.toHaveBeenCalled() fireEvent.keyDown(custom, { key: 'Enter' }) expect(screen.getByText('3 / 3')).toBeTruthy() }) it('shows the inline custom input, reports missing answers, and supports pager navigation', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy() fireEvent.click(screen.getByRole('radio', { name: '工程落地型' })) @@ -200,12 +268,12 @@ describe('QuestionComposer', () => { expect(screen.getByText('2 / 3')).toBeTruthy() fireEvent.click(screen.getByLabelText('上一题')) expect(screen.getByText('1 / 3')).toBeTruthy() - expect(respond).not.toHaveBeenCalled() + expect(answer).not.toHaveBeenCalled() }) it('answers over multiple lines: both fields grow with the draft and keep Shift+Enter a newline', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() // Both question shapes answer into a textarea, so the engine soft-wraps a // long answer and Shift+Enter breaks the line natively. @@ -233,40 +301,39 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) // Line breaks reach the model verbatim: nothing along the way flattens them. - expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ + expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: [], custom: multiline }, { id: 'detail', selected: [], custom: multiline }, { id: 'signals', selected: ['系统设计'] }, ])) }) - it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => { - const respond = vi.fn() - .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' }) + it('surfaces cancellation failures and re-arms the controls', async () => { + const { carrier, cancel } = wait() + cancel + .mockRejectedValueOnce(new Error('第一次取消失败')) .mockRejectedValueOnce(new Error('第二次取消失败')) - const { carrier } = wait('question-1', respond) - render() + render() - // Receipt rejection surfaces through the domain face's thrown message. fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) - expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy() + expect(await screen.findByText('第一次取消失败')).toBeTruthy() expect(screen.getByRole('button', { name: '跳过本题' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('第二次取消失败')).toBeTruthy() }) - it('surfaces transport rejection and resets local drafts for a different request', async () => { - const respond = vi.fn() - .mockRejectedValueOnce(new Error('网络中断')) - .mockRejectedValueOnce('字符串错误') - const first = wait('first', respond) - const view = render() + it('surfaces answer rejection and resets local drafts for a different request', async () => { + const first = wait() + const view = render() fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ })) expect(screen.getByText('2 / 3')).toBeTruthy() - const second = wait('second', respond) - view.rerender() + const second = wait() + second.answer + .mockRejectedValueOnce(new Error('网络中断')) + .mockRejectedValueOnce('字符串错误') + view.rerender() expect(screen.getByRole('radio', { name: /研究潜力型/ }).getAttribute('aria-checked')).toBe('false') fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) @@ -275,7 +342,7 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter' }) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) - expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [ + expect(second.answer).toHaveBeenNthCalledWith(1, answerBatch([ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', selected: [], custom: 'x' }, { id: 'signals', selected: ['系统设计'] }, @@ -288,64 +355,54 @@ describe('QuestionComposer', () => { }) it('renders chrome copy through the English dictionary', () => { - const respond = vi.fn(() => Promise.resolve({ accepted: true })) - const carrier = new PendingWait( - 'question', RpcId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond) - render() + const { carrier } = wait([{ id: 'detail', question: '补充你的要求' }]) + render() expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy() expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy() expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy() }) - it('same-key carrier replacement (baseline replay) keeps drafts', () => { - const first = wait('same-id') - const view = render() + it('keeps drafts when the same pending request rerenders', () => { + const pending = wait() + const view = render() fireEvent.click(screen.getByRole('radio', { name: /研究潜力型/ })) expect(screen.getByText('2 / 3')).toBeTruthy() - // Replay mints a NEW carrier for the same request; same key = no remount. - const replayed = wait('same-id') - view.rerender() + view.rerender() expect(screen.getByText('2 / 3')).toBeTruthy() }) }) describe('PendingQuestion domain face', () => { - it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => { - const respond = vi.fn() - .mockResolvedValueOnce({ accepted: true }) - .mockResolvedValueOnce({ accepted: false, reason: 'not-pending' }) - const question = new PendingQuestion(wait('rq', respond).carrier) + it('resolves the waterfall result with the answer batch and settles once', async () => { + const question = new PendingQuestion(SID, QUESTIONS) const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] } await expect(question.answer(batch)).resolves.toBeUndefined() - expect(respond).toHaveBeenCalledWith(answeredEnvelope('rq', batch.answers)) - await expect(question.answer(batch)).rejects.toThrow(/question response rejected: not-pending/) + await expect(question.result).resolves.toBe(batch) + await expect(question.answer(batch)).rejects.toThrow(/already settled/) }) - it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => { - const respond = vi.fn() - .mockResolvedValueOnce({ accepted: true }) - .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' }) - const question = new PendingQuestion(wait('rc', respond).carrier) + it('rejects the waterfall result with ASK_CANCELLED and settles once', async () => { + const question = new PendingQuestion(SID, QUESTIONS) + const result = question.result.catch((error: unknown) => error) await expect(question.cancel()).resolves.toBeUndefined() - expect(respond).toHaveBeenCalledWith({ - type: 'client-response', rpcId: RpcId('rc'), - result: { - ok: false, - error: { code: 'cancelled', message: 'the user closed this question request', details: {} }, - }, + await expect(result).resolves.toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + message: 'the user cancelled ask_user_question', }) - await expect(question.cancel()).rejects.toThrow(/question cancellation rejected: bad-response/) + await expect(question.cancel()).rejects.toThrow(/already settled/) }) - it('forwards key and questions from the carrier', () => { - const question = new PendingQuestion(wait('rk').carrier) - expect(question.key).toBe('q:rk') - expect(question.questions).toBe(wait('rk').carrier.payload.questions) + it('exposes its Client render identity and scoped request values', () => { + const question = new PendingQuestion(SID, QUESTIONS) + expect(question.key).toMatch(/^question:\d+$/) + expect(question.sessionId).toBe(SID) + expect(question.questions).toBe(QUESTIONS) }) it('collapses the card to the header strip and expands it back', () => { const { carrier } = wait() - render() + render() // Expanded: the option list is visible. expect(screen.getByRole('radiogroup')).toBeTruthy() // Collapse: options leave the tree; the title and minimize toggle stay. @@ -360,8 +417,8 @@ describe('PendingQuestion domain face', () => { }) it('keeps the collapse toggle out of the cancel path and preserves drafts across collapse', () => { - const { carrier, respond } = wait() - render() + const { carrier, answer } = wait() + render() fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) // Single-select auto-advances to the second question; collapse and expand // must not lose either the picked option or the current position. @@ -375,7 +432,7 @@ describe('PendingQuestion domain face', () => { fireEvent.click(screen.getByLabelText('下一题')) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) - expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ + expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', custom: '要能独立排查线上问题', selected: [] }, { id: 'signals', selected: ['系统设计'] }, diff --git a/packages/client/ui-user-questions/tsconfig.json b/packages/client/ui-user-questions/tsconfig.json index 88bc189297..e9d731d713 100644 --- a/packages/client/ui-user-questions/tsconfig.json +++ b/packages/client/ui-user-questions/tsconfig.json @@ -15,10 +15,19 @@ "path": "../../../vendor/cordis" }, { - "path": "../locale" + "path": "../../api/session-controller/tsconfig.client.json" }, { - "path": "../runtime" + "path": "../../core/session" + }, + { + "path": "../../interaction/user-questions" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../locale" }, { "path": "../ui-conversation" @@ -29,6 +38,12 @@ { "path": "../ui-slots" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 9ab72c7270..ac9548938b 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -32,9 +32,12 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } @@ -51,9 +54,12 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", @@ -61,12 +67,16 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index fe0f300211..56237975ff 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -7,14 +7,16 @@ import { type DisclosureRowProps, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import { shallowEqual } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkflowRunKey } from './locales.ts' import type { WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus, } from './workflow-definition.ts' import css from './WorkflowRunPanel.module.css' -/** Navigation action injected from the plugin's own SessionRuntime access. */ +/** Navigation action injected from the plugin's own Session Controller access. */ export interface WorkflowRunInjected { readonly openSession: (id: SessionId) => void } diff --git a/packages/client/ui-workflow-run/src/client/index.ts b/packages/client/ui-workflow-run/src/client/index.ts index 09cc4059bf..3ad36218ac 100644 --- a/packages/client/ui-workflow-run/src/client/index.ts +++ b/packages/client/ui-workflow-run/src/client/index.ts @@ -1,8 +1,12 @@ /** Browser plugin for durable workflow-run Conversation Nodes. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { WorkflowRunPanel, type WorkflowRunInjected } from './WorkflowRunPanel.tsx' import { en, NS, type WorkflowRunKey, zh } from './locales.ts' import { workflowRunDefinition } from './workflow-definition.ts' @@ -15,11 +19,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Required services for Definition, keyed renderer, navigation, and copy. */ -export const inject = ['conversationEvents', 'slots', 'sessions', 'locale'] +export const inject = ['uiConversation', 'slots', 'sessions', 'locale'] /** Register the workflow Definition, dictionary, and keyed Chat renderer. */ export function apply(ctx: ClientContext): void { - ctx.conversationEvents.register(workflowRunDefinition) + ctx.uiConversation.events.register(workflowRunDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workflow-run: dictionaries') ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', diff --git a/packages/client/ui-workflow-run/src/client/workflow-definition.ts b/packages/client/ui-workflow-run/src/client/workflow-definition.ts index 0e674bd530..7f1eba7708 100644 --- a/packages/client/ui-workflow-run/src/client/workflow-definition.ts +++ b/packages/client/ui-workflow-run/src/client/workflow-definition.ts @@ -1,7 +1,7 @@ import type { - ChatConversationViewNode, ConversationLocation, ConversationNodeContext, - ConversationNodeDefinition, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, @@ -34,7 +34,7 @@ export interface WorkflowRunChatData { readonly phases: readonly WorkflowRunPhaseData[] } -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { +declare module '@deepseek-ai/dsh-client-ui-chat/client' { interface ChatNodeDataMap { /** Durable top-level workflow run and all members that actually started. */ 'workflow-run': WorkflowRunChatData diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 248f932639..9016b974d9 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -3,14 +3,20 @@ import { Context, Service } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { - ConversationEventRegistry, ConversationNodeAssembler, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationNodeAssembler, UiConversation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { - ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition, - ConversationViewDefinition, SessionId, SessionListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ConversationEventInput, ConversationMatch, ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { + chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot, + stubSettingsScope, workspaceSnapshot, +} from '@deepseek-ai/dsh-client-test-runtime' import { WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps, } from '../src/client/WorkflowRunPanel.tsx' @@ -29,6 +35,22 @@ const PARENT_ID = 'parent' as SessionId const CHILD_ID = 'child-1' as SessionId const SECOND_ID = 'child-2' as SessionId +type TrajectoryState = Parameters[0]>[0] + +const panelSession = sessionSnapshot(PARENT_ID) +const panelAttention = new Map() +const panelWorkspace = workspaceSnapshot() +const panelConversation = conversationSnapshot() +const panelChat = emptyChatSnapshot() +const panelTrajectory: TrajectoryState = { + eventNodes: [], + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], +} + interface ChatSnapshot { readonly nodes: ReadonlyMap } @@ -63,7 +85,7 @@ const chatViewDefinition: ConversationViewDefinition selector(sessions), - useSession: (() => undefined) as WorkflowRunPanelProps['useSession'], + useSessionPendingInteraction: selector => selector(panelAttention), + useSession: selector => selector(panelSession), useProjection: () => undefined, + useConversation: selector => selector(panelConversation), + useChat: selector => selector(panelChat), + useTrajectory: selector => selector(panelTrajectory), useInput: () => { throw new Error('unused') }, - inputActions: { setDraft: () => {}, submit: () => {} } as unknown as WorkflowRunPanelProps['inputActions'], - useWorkspaces: (() => undefined) as WorkflowRunPanelProps['useWorkspaces'], + inputActions: { + setDraft: () => {}, + addImages: () => false, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + }, + useWorkspaces: selector => selector(panelWorkspace), useTurnData: () => undefined, - selectedCallId: undefined, - cwd: undefined, openFile: () => {}, inspectCall: () => {}, forkAt: () => {}, @@ -845,8 +875,8 @@ describe('plugin lifecycle', () => { ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await ctx.plugin(ConversationEventRegistry).await() await ctx.plugin(TestSessions).await() + const conversationEvents = new UiConversation(ctx, ctx.sessions).events ctx.slots.register({ name: 'root', children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } }, @@ -854,19 +884,19 @@ describe('plugin lifecycle', () => { await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) + expect(conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1) const entry = ctx.slots.entries('conversation.chat.node')[0]! const face = entry.inject?.() as unknown as WorkflowRunInjected face.openSession(CHILD_ID) expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID]) await fiber.dispose() - expect(ctx.conversationEvents.entries()).toEqual([]) + expect(conversationEvents.entries()).toEqual([]) expect(ctx.slots.entries('conversation.chat.node')).toEqual([]) const replacement = ctx.plugin({ inject: [...inject], apply }) await replacement.await() - expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) + expect(conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1) await replacement.dispose() }) diff --git a/packages/client/ui-workflow-run/tsconfig.json b/packages/client/ui-workflow-run/tsconfig.json index 7ef4d4625d..643952f137 100644 --- a/packages/client/ui-workflow-run/tsconfig.json +++ b/packages/client/ui-workflow-run/tsconfig.json @@ -15,11 +15,23 @@ "path": "../locale" }, { - "path": "../runtime" + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../store" + }, + { + "path": "../ui-chat" }, { "path": "../ui-conversation" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 8ce02c90bc..73b7c6650d 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -31,11 +31,18 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-api-session-controller/client", + "@deepseek-ai/dsh-api-workspace-controller/client" + ], "inject": [ + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-sidebar" ], "platform": "web" @@ -50,24 +57,33 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index c72d8cab39..b1801cc3d9 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -14,8 +14,8 @@ import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import type { - WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' + WorkspaceId, WorkspaceSnapshot, WorkspaceView, +} from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -31,7 +31,7 @@ export interface WorkspacePickFlowProps { /** The anchor button element — the popover's placement anchor. */ anchorRef?: RefObject | undefined /** Selector hook over the workspace list (framework standard hook). */ - useWorkspaces: (selector: (state: WorkspaceListState) => S) => S + useWorkspaces: (selector: (state: WorkspaceSnapshot) => S) => S /** Adopt a picked host directory as a real Workspace. */ createWorkspace: (input: { path: string }) => Promise /** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */ diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index bdd85052a0..f25270aad3 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -28,9 +28,9 @@ import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRu // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { - SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionSearchResultItem } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { createWorkspaceViewStore } from '../stores.ts' /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 93269c22c1..b33adad512 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -8,17 +8,29 @@ * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ +import type { Context } from '@deepseek-ai/cordis' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { IWorkspaces, WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the Controller service merges. +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-api-workspace-controller/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: pulls the Session root standard-hook merge. +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' +import { UiWorkspaceService } from './navigation.ts' import { createWorkspaceViewStore } from './stores.ts' -import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' +import { WorkspaceBrowser } from './rows/WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' import { en, zh, type WorkspaceKey } from './locales.ts' +export { DirectoryBrowseError } from './navigation.ts' +export type { UiWorkspace } from './navigation.ts' export type { DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, @@ -26,6 +38,11 @@ export type { export type { WorkspaceKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Selector hook over the pure Workspace Controller snapshot. */ + useWorkspaces: SnapshotSelectorHook + } + interface LocaleNamespaceMap { /** The workspace browsing region and pick/create flow copy. */ workspace: WorkspaceKey @@ -51,13 +68,17 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection' * framework's global hooks. * @param ctx - client root context. */ -export function apply(ctx: ClientContext): void { +export function apply(ctx: Context): void { const connection = ctx.get('connection') as ConnectionHandle + const sessions = ctx.get('sessions') as ISessions + const workspaces = ctx.get('workspaces') as IWorkspaces const hostDescription = connection.hostDescription + const uiWorkspace = new UiWorkspaceService(ctx, connection.api, workspaces, sessions) + ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => { - const result = await ctx.sessions.search(query, signal) + const result = await sessions.search(query, signal) if (!result.ok) throw new Error(result.error.message) return result.value } @@ -73,39 +94,39 @@ export function apply(ctx: ClientContext): void { const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session inherits // the current Session Workspace before the recent-Workspace fallback. - startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, - open: (sessionId) => { ctx.sessions.open(sessionId) }, + startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) }, + open: (sessionId) => { sessions.open(sessionId) }, searchSessions, - searchResultLimit: ctx.sessions.searchResultLimit, + searchResultLimit: sessions.searchResultLimit, renameSession: async (sessionId, title) => { // Row → session-face hop: rename is a per-session verb (ISession), not // a list-service verb; the binding resolves any listed session. - const session = ctx.sessions.binding(sessionId)?.session + const session = sessions.binding(sessionId)?.session if (session === undefined) throw new Error(`unknown session "${sessionId}"`) const result = await session.rename(title) if (!result.ok) throw new Error(result.error.message) }, forkSession: (sessionId) => { - ctx.sessions.fork({ sessionId, increaseTitle: true }) - .then((childId) => { ctx.sessions.open(childId) }) + sessions.fork({ sessionId, increaseTitle: true }) + .then((childId) => { sessions.open(childId) }) .catch(() => { // Fork or child-rename failure keeps the current selection. }) }, - renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, - deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, + renameWorkspace: async (workspaceId, title) => { await workspaces.rename(workspaceId, title) }, + deleteWorkspace: async (workspaceId) => { await workspaces.delete(workspaceId) }, insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => { - await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId) + await workspaces.insertBefore(workspaceId, beforeWorkspaceId) }, - archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) }, + archiveSession: async (sessionId) => { await uiWorkspace.archiveSession(sessionId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { - await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, - createWorkspace: input => ctx.workspaces.create(input), + createWorkspace: input => workspaces.create(input), hooks: { directoryFlow: browserFlowSource, hostDescription }, }) const pickerInjected = (): WorkspacePickerInjected => ({ - createWorkspace: input => ctx.workspaces.create(input), + createWorkspace: input => workspaces.create(input), hooks: { directoryFlow: pickerFlowSource }, }) // Each registration declares its directory-flow child in the same call; diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts new file mode 100644 index 0000000000..7d209baec9 --- /dev/null +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -0,0 +1,248 @@ +/** Workspace archive and directory UI capability. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import type { + DirectoryListing, IApiClient, RpcError, +} from '@deepseek-ai/dsh-client-connection/client' +import type { + ISessions, + SessionListState, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { + IWorkspaces, WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Workspace archive and directory operations consumed by Client UI domains. */ +export interface UiWorkspace { + /** + * Resolve the reusable or newly created blank Session for a Workspace. + * @param workspaceId - target Workspace. + * @returns a Session already addressable through the Session Controller. + */ + connectWorkspace(workspaceId: WorkspaceId): Promise + /** + * Start a New Session flow and navigate to its Session. + * @param workspaceId - explicit target; absent inherits the current or most recent Workspace. + */ + startSession(workspaceId?: WorkspaceId): void + /** + * Archive a Session and clear it when it is the current selection. + * @param sessionId - Session to archive. + */ + archiveSession(sessionId: SessionId): Promise + /** @returns the Host-native picked directory, or null when cancelled. */ + pickDirectory(): Promise + /** + * List one Host directory level. + * @param path - directory path; absent selects the Host home. + * @param signal - cancellation for a superseded scan. + * @returns directory entries and breadcrumb ancestry. + */ + listDirectory(path?: string, signal?: AbortSignal): Promise + /** + * Create a child directory. + * @param path - existing parent directory. + * @param name - child directory name. + * @returns created absolute path. + */ + createDirectory(path: string, name: string): Promise + /** + * Open a path with the Host operating system. + * @param path - absolute or Host-resolvable path. + */ + openPath(path: string): Promise +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Cross-Controller Workspace navigation and directory UI capability. */ + uiWorkspace: UiWorkspace + } +} + +/** Structured directory failure exposed to directory UI consumers. */ +export class DirectoryBrowseError extends Error { + override readonly name = 'DirectoryBrowseError' + + /** @param rpcError - Host directory business failure. */ + constructor(readonly rpcError: RpcError) { + super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`) + } +} + +/** Implements Workspace archive and directory UI operations. */ +class UiWorkspaceService extends Service implements UiWorkspace { + private readonly connecting = new Map>() + + /** + * @param ctx - Client root Context. + * @param api - shared Host API carrier. + * @param workspaces - pure Workspace Controller. + * @param sessions - pure Session Controller. + */ + constructor( + ctx: Context, + private readonly api: IApiClient, + private readonly workspaces: IWorkspaces, + private readonly sessions: ISessions, + ) { + super(ctx, 'uiWorkspace') + ctx.effect(() => this.watchNavigation(), 'ui-workspace: Workspace navigation policy') + } + + async connectWorkspace(workspaceId: WorkspaceId): Promise { + const workspace = this.workspaces.list.getSnapshot().items + .find(item => item.workspaceId === workspaceId) + if (workspace === undefined) { + throw new Error(`uiWorkspace.connectWorkspace: unknown workspace ${workspaceId}`) + } + const inflight = this.connecting.get(workspaceId) + if (inflight !== undefined) return inflight + + const archived = this.workspaces.list.getSnapshot().archivedSessionIds + const sessions = this.sessions.list.getSnapshot() + for (const id of sessions.ids) { + const summary = sessions.byId[id] + if (summary !== undefined && summary.blank && summary.cwd === workspace.path + && workspace.sessionIds.includes(summary.id) + && !archived.includes(summary.id)) return summary.id + } + + const attempt = this.sessions.create({ workspaceId }) + .finally(() => { this.connecting.delete(workspaceId) }) + this.connecting.set(workspaceId, attempt) + return attempt + } + + startSession(workspaceId?: WorkspaceId): void { + const workspace = this.workspaces.list.getSnapshot() + const sessions = this.sessions.list.getSnapshot() + const current = sessions.current + const currentWorkspaceId = current === undefined + ? undefined + : workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId + const recent = workspace.phase === 'ready' && sessions.phase === 'ready' + ? recentWorkspace(workspace.items, sessions.byId) + : undefined + const target = workspaceId ?? currentWorkspaceId ?? recent + if (target === undefined) { + this.sessions.clear() + return + } + void this.connectWorkspace(target).then( + (sessionId) => { this.sessions.open(sessionId) }, + (reason: unknown) => { console.warn('new session failed:', reason) }, + ) + } + + async archiveSession(sessionId: SessionId): Promise { + await this.workspaces.archiveSession(sessionId) + } + + async pickDirectory(): Promise { + const response = await this.api.host.pickDirectory({}) + if (!response.result.ok) { + throw new Error(`directory picker failed: ${response.result.error.message}`) + } + return response.result.value.path + } + + async listDirectory(path?: string, signal?: AbortSignal): Promise { + const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) + if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) + return response.result.value + } + + async createDirectory(path: string, name: string): Promise { + const response = await this.api.host.createDirectory({ path, name }) + if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) + return response.result.value.path + } + + async openPath(path: string): Promise { + const response = await this.api.host.openPath({ path }) + if (!response.result.ok) { + throw new Error(`path open failed: ${response.result.error.message}`) + } + } + + private watchNavigation(): () => void { + let initial: 'waiting' | 'connecting' | 'done' = 'waiting' + let disposed = false + const reconcile = (): void => { + if (disposed) return + if (this.clearArchivedCurrent()) return + if (initial !== 'waiting') return + const workspace = this.workspaces.list.getSnapshot() + const sessions = this.sessions.list.getSnapshot() + if (workspace.phase !== 'ready' || sessions.phase !== 'ready') return + if (sessions.current !== undefined) { + initial = 'done' + return + } + const target = recentWorkspace(workspace.items, sessions.byId) + if (target === undefined) { + initial = 'done' + return + } + initial = 'connecting' + void this.connectWorkspace(target).then( + (sessionId) => { + if (disposed) return + if (this.sessions.list.getSnapshot().current === undefined) { + this.sessions.open(sessionId) + } + initial = 'done' + }, + (reason: unknown) => { + if (disposed) return + initial = 'waiting' + console.warn('initial workspace selection failed:', reason) + }, + ) + } + const disposeWorkspaces = this.workspaces.list.subscribe(reconcile) + const disposeSessions = this.sessions.list.subscribe(reconcile) + reconcile() + return () => { + disposed = true + disposeSessions() + disposeWorkspaces() + } + } + + /** @returns true when an archived current selection was cleared. */ + private clearArchivedCurrent(): boolean { + const current = this.sessions.list.getSnapshot().current + if (current === undefined + || !this.workspaces.list.getSnapshot().archivedSessionIds.includes(current)) return false + this.sessions.clear() + return true + } + +} + +/** Stable tie-breaking follows Host Workspace order. */ +function recentWorkspace( + workspaces: readonly WorkspaceView[], + sessions: SessionListState['byId'], +): WorkspaceId | undefined { + let selected: WorkspaceId | undefined + let selectedTime = Number.NEGATIVE_INFINITY + for (const workspace of workspaces) { + let latest = Number.NEGATIVE_INFINITY + for (const sessionId of workspace.sessionIds) { + const session = sessions[sessionId] + if (session !== undefined) latest = Math.max(latest, session.updatedAt) + } + if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt) + if (selected === undefined || latest > selectedTime) { + selected = workspace.workspaceId + selectedTime = latest + } + } + return selected +} + +export { UiWorkspaceService } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 8689da3cc9..3aa18981c4 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -13,7 +13,7 @@ import { IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' -import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' +import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import { relativeTime } from '../tree.ts' diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css similarity index 100% rename from packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css rename to packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx similarity index 95% rename from packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx rename to packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 08f22ed400..c95ad179e9 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -16,14 +16,16 @@ import { IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { - SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspaceBrowserProps } from './contract/slots.ts' -import type { SessionNode, SessionOrderBy } from './tree.ts' -import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts' -import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx' -import { FLAT_SESSION_ORDER_KEY } from './stores.ts' -import { WorkspacePickFlow } from './WorkspacePicker.tsx' + SessionListState, SessionSearchResultItem, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceBrowserProps } from '../contract/slots.ts' +import type { SessionNode, SessionOrderBy } from '../tree.ts' +import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from '../tree.ts' +import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx' +import { FLAT_SESSION_ORDER_KEY } from '../stores.ts' +import { WorkspacePickFlow } from '../WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' /** @@ -215,7 +217,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): type SessionTreeProps = Pick< WorkspaceBrowserProps, - 'useSessions' | 'startSession' | 'open' | 'forkSession' + 'useSessions' | 'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession' | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' > & { /** Host account home for POSIX hover-path abbreviation. */ @@ -249,13 +251,14 @@ type SessionTreeProps = Pick< /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ - useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, + useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, }: SessionTreeProps) { const list = useSessions(s => s) + const pendingInteractions = useSessionPendingInteraction(s => s) const current = list.current const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag marker state; the selected mode owns the resulting order. @@ -280,7 +283,7 @@ function SessionTree({ ) const ungroupedSessionIds = useMemo(() => { const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds)) - return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id)) + return list.ids.filter((id: SessionId) => list.byId[id] !== undefined && !accounted.has(id)) }, [list, workspaces]) useEffect(() => { if (list.phase !== 'ready') return @@ -321,13 +324,13 @@ function SessionTree({ [sessionOrderByAccount, ungroupedSessionIds], ) const groups = useMemo( - () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { + () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, pendingInteractions, { expandedGroups, ...(sessionOrderByAccount[UNGROUPED_KEY] === undefined ? {} : { ungroupedOrder: sessionOrderByAccount[UNGROUPED_KEY] }), }), - [list, orderedWorkspaces, archivedSessionIds, expandedGroups, sessionOrderByAccount], + [list, orderedWorkspaces, archivedSessionIds, pendingInteractions, expandedGroups, sessionOrderByAccount], ) const now = Date.now() const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { @@ -547,11 +550,13 @@ function SessionTree({ /** The flat "In one list" body: every session is one draggable top-level row. */ function FlatList({ - useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, + useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive, + archivedSessionIds, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t, }: Pick< SessionTreeProps, | 'useSessions' + | 'useSessionPendingInteraction' | 'open' | 'forkSession' | 'onSessionRename' @@ -565,9 +570,10 @@ function FlatList({ | 't' >) { const list = useSessions(s => s) + const pendingInteractions = useSessionPendingInteraction(s => s) const baseRows = useMemo( - () => deriveFlat(list, archivedSessionIds), - [list, archivedSessionIds], + () => deriveFlat(list, archivedSessionIds, pendingInteractions), + [list, archivedSessionIds, pendingInteractions], ) const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows]) const previousOrderBy = useRef(orderBy) @@ -675,6 +681,7 @@ interface RemoteSearchState { /** Flat search body: local metadata matches plus the current Host result page. */ function SearchResults({ useSessions, + useSessionPendingInteraction, open, workspaces, archivedSessionIds, @@ -682,7 +689,7 @@ function SearchResults({ remote, resultLimit, t, -}: Pick & { +}: Pick & { workspaces: readonly WorkspaceView[] archivedSessionIds: readonly SessionNode['id'][] query: string @@ -690,12 +697,21 @@ function SearchResults({ resultLimit: number }) { const list = useSessions(s => s) + const pendingInteractions = useSessionPendingInteraction(s => s) const currentRemote = remote.query === query ? remote : { query, status: 'loading' as const, items: [], hasMore: false } const results = useMemo( - () => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit), - [list, workspaces, query, archivedSessionIds, currentRemote, resultLimit], + () => deriveSearchResults( + list, + workspaces, + query, + archivedSessionIds, + pendingInteractions, + currentRemote, + resultLimit, + ), + [list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit], ) const pending = currentRemote.status === 'loading' const failed = currentRemote.status === 'error' @@ -745,6 +761,7 @@ export function WorkspaceBrowser({ wide, expandSidebar, useSessions, + useSessionPendingInteraction, useWorkspaces, useStore, actions, @@ -791,8 +808,9 @@ export function WorkspaceBrowser({ promotedBlank.current = undefined return } - if (promotedBlank.current?.sessionId === currentBlankSessionId - && promotedBlank.current.accountKey === currentBlankAccount) return + const promoted = promotedBlank.current + if (promoted !== undefined && promoted.sessionId === currentBlankSessionId + && promoted.accountKey === currentBlankAccount) return promotedBlank.current = { sessionId: currentBlankSessionId, accountKey: currentBlankAccount } for (const accountKey of new Set([currentBlankAccount, FLAT_SESSION_ORDER_KEY])) { const previous = sessionOrderByAccount[accountKey] ?? [] @@ -1145,6 +1163,7 @@ export function WorkspaceBrowser({ ? ( + /** One top-level session row in a group or the flat list. */ export interface SessionNode { id: SessionId @@ -22,8 +30,8 @@ export interface SessionNode { title: string /** The provisional blank session (renderer shows the localized New Session title). */ blank: boolean - /** The runtime Session list reports an interaction awaiting this user. */ - pendingInteraction?: PendingInteractionStatus + /** A Session-scoped UI consumer is awaiting this user. */ + pendingInteraction?: SessionPendingInteractionStatus running: boolean /** Running descendants connected through uninterrupted subagent-origin lineage. */ runningSubagentCount: number @@ -59,8 +67,8 @@ export interface SearchResultNode { id: SessionId title: string workspace: string - /** The runtime Session list reports an interaction awaiting this user. */ - pendingInteraction?: PendingInteractionStatus + /** A Session-scoped UI consumer is awaiting this user. */ + pendingInteraction?: SessionPendingInteractionStatus running: boolean /** Running descendants connected through uninterrupted subagent-origin lineage. */ runningSubagentCount: number @@ -211,10 +219,24 @@ function groupByWorkspace( return groups } +/** Keep navigation presentation independent from domain-owned interaction objects. */ +function visiblePendingKind(kind: string | undefined): SessionPendingInteractionStatus | undefined { + switch (kind) { + case 'approval': + case 'plan-review': + case 'question': + return kind + default: + return undefined + } +} + function sessionNode( s: SessionSummary, descendants: ReadonlyMap, + pendingInteractions: SessionPendingInteractions, ): SessionNode { + const pendingInteraction = visiblePendingKind(pendingInteractions.get(s.id)?.kind) return { id: s.id, title: sessionTitle(s), @@ -223,7 +245,7 @@ function sessionNode( runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0, completed: s.completed === true, updatedAt: s.updatedAt, - ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }), + ...(pendingInteraction === undefined ? {} : { pendingInteraction }), } } @@ -238,6 +260,7 @@ function sessionNode( * @param list - sessions list snapshot (`current` feeds containsCurrent). * @param workspaces - real workspaces in stable Host order. * @param archivedSessionIds - registry-global archive set. + * @param pendingInteractions - pending UI interactions by Session. * @param view - local expansion arrays. * @returns group sections in render order. */ @@ -245,6 +268,7 @@ export function deriveGroups( list: SessionListState, workspaces: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[], + pendingInteractions: SessionPendingInteractions, view: TreeView, ): GroupNode[] { const archived = new Set(archivedSessionIds) @@ -266,7 +290,9 @@ export function deriveGroups( sessionCount: g.sessions.length, expanded, containsCurrent: g.key === currentGroup, - sessions: expanded ? g.sessions.map(session => sessionNode(session, descendants)) : [], + sessions: expanded + ? g.sessions.map(session => sessionNode(session, descendants, pendingInteractions)) + : [], }) } return groups @@ -279,11 +305,13 @@ export function deriveGroups( * (see {@link deriveSearchResults}). * @param list - sessions list snapshot. * @param archivedSessionIds - registry-global archive set. + * @param pendingInteractions - pending UI interactions by Session. * @returns flat rows in render order. */ export function deriveFlat( list: SessionListState, archivedSessionIds: readonly SessionId[], + pendingInteractions: SessionPendingInteractions, ): SessionNode[] { const archived = new Set(archivedSessionIds) const descendants = indexSubagentDescendants(list.byId) @@ -294,7 +322,7 @@ export function deriveFlat( rows.push(s) } rows.sort(byRecency) - return rows.map(session => sessionNode(session, descendants)) + return rows.map(session => sessionNode(session, descendants, pendingInteractions)) } /** Relative-time bucket of a session row's trailing label. */ @@ -314,6 +342,7 @@ export interface RelativeTime { * @param workspaces - Workspace membership and display labels. * @param query - caller text; surrounding whitespace is ignored. * @param archivedSessionIds - registry-global archive set (members never match). + * @param pendingInteractions - pending UI interactions by Session. * @param content - ranked Host content-search page. * @param limit - protocol-owned maximum merged row count. * @returns bounded deduplicated flat rows and a refine-query hint bit. @@ -323,6 +352,7 @@ export function deriveSearchResults( workspaces: readonly WorkspaceView[], query: string, archivedSessionIds: readonly SessionId[], + pendingInteractions: SessionPendingInteractions, content: { items: readonly SessionSearchResultItem[]; hasMore: boolean }, limit: number, ): SearchResultSet { @@ -375,15 +405,16 @@ export function deriveSearchResults( return { items: ordered.slice(0, limit).map((summary) => { const match = contentBySession.get(summary.id) + const pendingInteraction = visiblePendingKind(pendingInteractions.get(summary.id)?.kind) return { id: summary.id, title: sessionTitle(summary), workspace: labelOf(summary), running: summary.running, runningSubagentCount: descendants.get(summary.id)?.runningCount ?? 0, - ...(summary.pendingInteraction === undefined + ...(pendingInteraction === undefined ? {} - : { pendingInteraction: summary.pendingInteraction }), + : { pendingInteraction }), completed: summary.completed === true, ...match === undefined ? {} : { snippet: match.snippet }, } diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 3ad62e289f..79aef72717 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -1,10 +1,10 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' -import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' +import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' async function bench() { @@ -15,7 +15,6 @@ async function bench() { path: 'name' in input ? `/projects/${input.name}` : input.path, title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) - const startSession = vi.fn() const rename = vi.fn(async () => ({})) const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() @@ -27,10 +26,37 @@ async function bench() { const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } })) const binding = vi.fn(() => ({ session: { rename: renameSession } })) const fork = vi.fn(async () => 'forked' as never) + const subscribe = () => () => {} ctx.provide('workspaces', { - create, startSession, rename, insertSessionBefore, + list: { + getSnapshot: () => ({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + }), + subscribe, + }, + create, + rename, + delete: vi.fn(async () => undefined), + insertBefore: vi.fn(async () => undefined), + archiveSession: vi.fn(async () => undefined), + insertSessionBefore, + } as never) + ctx.provide('sessions', { + list: { + getSnapshot: () => ({ + ids: [], byId: {}, current: undefined, phase: 'ready', + subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + }), + subscribe, + }, + create: vi.fn(async () => 'created' as never), + open, + clear, + search, + searchResultLimit: 20, + binding, + fork, } as never) - ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never) ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) @@ -41,7 +67,7 @@ async function bench() { locale.setLocale('zh') ctx.provide('locale', locale) return { - ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, startSession, rename, + ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, insertSessionBefore, open, clear, search, renameSession, binding, fork, } } @@ -56,7 +82,9 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale', 'connection']) + expect(inject).toEqual([ + 'slots', 'sessions', 'workspaces', 'locale', 'connection', + ]) }) it('registers browser and pickers for declarations arriving before or after apply', async () => { @@ -81,13 +109,14 @@ describe('ui-workspace apply', () => { const b = await bench() declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace') await b.ctx.plugin({ inject: [...inject], apply }).await() + const startSession = vi.spyOn(b.ctx.uiWorkspace, 'startSession').mockImplementation(() => undefined) const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() - // Both arms delegate to the runtime's shared New Session action. + // Both arms delegate to the shared Session navigation action. browser.startSession('ws' as never) - expect(b.startSession).toHaveBeenCalledWith('ws') + expect(startSession).toHaveBeenCalledWith('ws') browser.startSession() - expect(b.startSession).toHaveBeenLastCalledWith(undefined) + expect(startSession).toHaveBeenLastCalledWith(undefined) browser.open('session' as never) expect(b.open).toHaveBeenCalledWith('session') const signal = new AbortController().signal @@ -143,7 +172,7 @@ describe('ui-workspace apply', () => { unsubscribe() }) - it('rejects the browser search callback on a runtime business error', async () => { + it('rejects the browser search callback on a Session Controller business error', async () => { const b = await bench() b.search.mockImplementationOnce(async () => ({ ok: false, diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 5554c977bb..3d0f31b318 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8') +const css = readFileSync(fileURLToPath(new URL('../src/client/rows/WorkspaceBrowser.module.css', import.meta.url)), 'utf8') const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8') /** diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index c2e201469f..7db2ebd545 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -7,13 +7,15 @@ * the list state — no push-frame wait. Coverage split: the assembled-app * snapshot (apps/web/tests/session-actions.snapshot.ts) pins the full-app * transcript; the - * verb's wire behavior stays with the runtime package + * verb's wire behavior stays with the Session Controller client package * (session.spec.ts#rename), the dialog's own arms with rows.spec / * workspace-browser.spec. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' -import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -31,11 +33,12 @@ beforeEach(() => { localStorage.clear() }) /** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { + runtime.releaseWorkspaceSource() + runtime.ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) const locale = new LocaleRuntime(runtime.ctx) - runtime.provide('locale', locale) + runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) return runtime } diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index b7f7844721..b6b0429467 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { RowDragProps } from '../src/client/rows/Rows.tsx' diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index f2e069de43..767109dd98 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { - SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-session/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, relativeTime, UNGROUPED_KEY, UNGROUPED_LABEL, @@ -29,28 +30,51 @@ const view = (expandedGroups: readonly string[] = [], ungroupedOrder?: readonly ...(ungroupedOrder === undefined ? {} : { ungroupedOrder }), }) const noArchive: readonly SessionId[] = [] +const noAttention: ReadonlyMap = new Map() const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid) describe('deriveGroups', () => { it('keeps Host Workspace and sessionIds order without Client recency sorting', () => { const sessions = list(summary('newer', 20), summary('older', 10)) const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])] - const groups = deriveGroups(sessions, workspaces, noArchive, view(['first'])) + const groups = deriveGroups(sessions, workspaces, noArchive, noAttention, view(['first'])) expect(groups.map(group => group.key)).toEqual(['first', 'empty']) expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) it('projects pending-interaction state into grouped and flat rows', () => { - const awaiting = { ...summary('awaiting', 10), pendingInteraction: 'plan-review' as const, running: true } + const awaiting = { ...summary('awaiting', 10), running: true } const sessions = list(awaiting) - const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project'])) + const attention: ReadonlyMap = new Map([[ + awaiting.id, + { key: 'question:1', kind: 'plan-review', sessionId: awaiting.id }, + ]]) + const grouped = deriveGroups( + sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']), + ) expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true }) - expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true }) + expect(deriveFlat(sessions, noArchive, attention)[0]) + .toMatchObject({ pendingInteraction: 'plan-review', running: true }) }) + it.each(['approval', 'question'] as const)( + 'projects the %s pending-interaction kind', + (kind) => { + const awaiting = summary(kind, 10) + const attention: ReadonlyMap = new Map([[ + awaiting.id, + { key: `${kind}:1`, kind, sessionId: awaiting.id }, + ]]) + + expect(deriveFlat(list(awaiting), noArchive, attention)[0]?.pendingInteraction).toBe(kind) + }, + ) + it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) - const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY])) + const groups = deriveGroups( + sessions, [workspace('first', ['owned'])], noArchive, noAttention, view([UNGROUPED_KEY]), + ) expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY]) expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) }) @@ -61,6 +85,7 @@ describe('deriveGroups', () => { sessions, [], noArchive, + noAttention, view([UNGROUPED_KEY], ['two', 'stale', 'two']), ) expect(groups[0]!.sessions.map(session => session.id)).toEqual([ @@ -77,7 +102,8 @@ describe('deriveGroups', () => { current: currentBlank.id, } const groups = deriveGroups( - sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']), + sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], + noArchive, noAttention, view(['first']), ) expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id]) const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)! @@ -88,7 +114,10 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false) expect(groups[0]!.sessionCount).toBe(2) // A non-current blank stray never surfaces an Ungrouped bucket either. - const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view()) + const strayGroups = deriveGroups( + list({ ...summary('stray', 2), blank: true }), + [workspace('first', [])], noArchive, noAttention, view(), + ) expect(strayGroups.map(group => group.key)).toEqual(['first']) }) @@ -97,14 +126,17 @@ describe('deriveGroups', () => { const plain = summary('plain', 2) const sessions = list(done, plain) const groups = deriveGroups( - sessions, [workspace('first', ['done', 'plain'])], noArchive, view(['first']), + sessions, [workspace('first', ['done', 'plain'])], noArchive, noAttention, view(['first']), ) const doneNode = groups[0]!.sessions.find(session => session.id === done.id)! const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)! expect(doneNode.completed).toBe(true) expect(plainNode.completed).toBe(false) - expect(deriveFlat(sessions, noArchive).find(node => node.id === done.id)!.completed).toBe(true) - const search = deriveSearchResults(sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, { items: [], hasMore: false }, 10) + expect(deriveFlat(sessions, noArchive, noAttention).find(node => node.id === done.id)!.completed).toBe(true) + const search = deriveSearchResults( + sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, + noAttention, { items: [], hasMore: false }, 10, + ) expect(search.items[0]?.completed).toBe(true) }) @@ -125,6 +157,7 @@ describe('deriveGroups', () => { sessions, [workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])], noArchive, + noAttention, view(['first']), ) @@ -132,12 +165,12 @@ describe('deriveGroups', () => { expect(groups[0]!.sessionCount).toBe(2) expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 }) expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 }) - expect(deriveFlat(sessions, noArchive).map(node => [node.id, node.runningSubagentCount])).toEqual([ + expect(deriveFlat(sessions, noArchive, noAttention).map(node => [node.id, node.runningSubagentCount])).toEqual([ [fork.id, 1], [parent.id, 2], ]) expect(deriveSearchResults( sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive, - { items: [], hasMore: false }, 10, + noAttention, { items: [], hasMore: false }, 10, ).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 }) }) @@ -155,6 +188,7 @@ describe('deriveGroups', () => { list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), [], noArchive, + noAttention, { expandedGroups: [UNGROUPED_KEY] }, ) @@ -165,7 +199,9 @@ describe('deriveGroups', () => { ]) // Equal timestamps use ids as a deterministic tiebreak in either input order. - expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]! + expect(deriveGroups( + list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noAttention, view([UNGROUPED_KEY]), + )[0]! .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')]) }) @@ -175,7 +211,9 @@ describe('deriveGroups', () => { ids: [sid('present')], byId: { [sid('present')]: summary('present', 1) }, } - const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project'])) + const groups = deriveGroups( + partial, [workspace('project', ['missing', 'present'])], noArchive, noAttention, view(['project']), + ) expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) }) @@ -185,7 +223,8 @@ describe('deriveGroups', () => { const looseGone = summary('loose-gone', 3, '/other') const sessions = list(kept, gone, looseGone) const groups = deriveGroups( - sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]), + sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), + noAttention, view(['first', UNGROUPED_KEY]), ) // The archived member drops from its group AND the archived stray never // surfaces an Ungrouped bucket; counts follow the visible rows. @@ -198,9 +237,13 @@ describe('deriveGroups', () => { const owned = summary('owned', 1) const loose = summary('loose', 2) const ws = workspace('project', ['owned']) - const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view()) + const ownedGroups = deriveGroups( + { ...list(owned, loose), current: owned.id }, [ws], noArchive, noAttention, view(), + ) expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true) - const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view()) + const looseGroups = deriveGroups( + { ...list(owned, loose), current: loose.id }, [ws], noArchive, noAttention, view(), + ) expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true) }) }) @@ -211,7 +254,7 @@ describe('deriveFlat', () => { const child = { ...summary('child', 30), parentId: parent.id } const tieB = summary('tie-b', 20) const tieA = summary('tie-a', 20) - const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive) + const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noAttention) expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) }) @@ -222,13 +265,14 @@ describe('deriveFlat', () => { const rows = deriveFlat( { ...list(parent, fork, subagent), current: subagent.id }, noArchive, + noAttention, ) expect(rows.map(row => row.id)).toEqual([fork.id, parent.id]) }) it('tolerates ids whose summary has not landed yet', () => { const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } - expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')]) + expect(deriveFlat(partial, noArchive, noAttention).map(row => row.id)).toEqual([sid('present')]) }) it('shows only the current blank session and excludes blanks from search', () => { @@ -238,7 +282,7 @@ describe('deriveFlat', () => { ...list(summary('real', 1), currentBlank, staleBlank), current: currentBlank.id, } - const rows = deriveFlat(sessions, noArchive) + const rows = deriveFlat(sessions, noArchive, noAttention) expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')]) expect(rows.map(row => row.title)).toEqual(['New Session', 'real']) expect(rows.map(row => row.blank)).toEqual([true, false]) @@ -247,7 +291,7 @@ describe('deriveFlat', () => { it('hides archived sessions in flat mode', () => { const kept = summary('kept', 1) const gone = summary('gone', 2) - expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id]) + expect(deriveFlat(list(kept, gone), archived('gone'), noAttention).map(row => row.id)).toEqual([kept.id]) }) }) @@ -262,6 +306,7 @@ describe('deriveSearchResults archive filtering', () => { [], 'needle', archived('gone'), + noAttention, { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false }, 10, ) @@ -273,7 +318,6 @@ describe('deriveSearchResults', () => { it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => { const titleHit = summary('title-hit', 30, '/projects/a') titleHit.displayTitle = 'Needle title' - titleHit.pendingInteraction = 'plan-review' const workspaceHit = summary('workspace-hit', 20, '/projects/b') workspaceHit.displayTitle = 'Ordinary title' const contentHit = summary('content-hit', 10, '/projects/c') @@ -287,6 +331,9 @@ describe('deriveSearchResults', () => { ], ' NEEDLE ', noArchive, + new Map([[titleHit.id, { + key: 'question:1', kind: 'plan-review', sessionId: titleHit.id, + }]]), { items: [ { sessionId: contentHit.id, snippet: 'body needle excerpt' }, @@ -347,6 +394,7 @@ describe('deriveSearchResults', () => { [workspace('first', ['opaque-current', 'new session stale'])], 'new session', noArchive, + noAttention, { items: [ { sessionId: staleBlank.id, snippet: 'stale body' }, @@ -370,6 +418,7 @@ describe('deriveSearchResults', () => { [], 'needle', noArchive, + noAttention, { items: [], hasMore: false }, 3, ) @@ -381,12 +430,13 @@ describe('deriveSearchResults', () => { [], 'needle', noArchive, + noAttention, { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true }, 3, ) expect(backendMore.items).toHaveLength(1) expect(backendMore.hasMore).toBe(true) - expect(deriveSearchResults(list(), [], ' ', noArchive, { items: [], hasMore: true }, 3)) + expect(deriveSearchResults(list(), [], ' ', noArchive, noAttention, { items: [], hasMore: true }, 3)) .toEqual({ items: [], hasMore: false }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 820d2bd66b..b15650e888 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -2,15 +2,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { - SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' + WorkspaceId, WorkspaceSnapshot, WorkspaceView, +} from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' import { UNGROUPED_KEY } from '../src/client/tree.ts' -import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' +import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' import { zh } from '../src/client/locales.ts' afterEach(cleanup) @@ -38,10 +41,11 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }) -const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = []): WorkspaceListState => ({ - items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true, - recentWorkspaceId: items[0]?.workspaceId, -}) +const workspaceState = ( + items: readonly WorkspaceView[], + archivedSessionIds: readonly SessionId[] = [], +): WorkspaceSnapshot => ({ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null }) +const noPendingInteraction: SessionPendingInteractionSnapshot = new Map() function hook(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } } @@ -64,6 +68,7 @@ function mount(overrides: Partial = {}) { wide: true, expandSidebar: vi.fn(), useSessions: hook(sessionState([])), + useSessionPendingInteraction: hook(noPendingInteraction), useWorkspaces: hook(workspaceState([])), useStore: bindSnapshotSelector(store), actions: store.actions, @@ -122,7 +127,6 @@ describe('WorkspaceBrowser', () => { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const, - baselinesReady: false, } const b = mount({ useWorkspaces: hook(pending) }) act(() => { diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index 87e3a3943b..816f78e29c 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -1,11 +1,14 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { - SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' + WorkspaceId, WorkspaceSnapshot, WorkspaceView, +} from '@deepseek-ai/dsh-api-workspace-controller/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' @@ -30,9 +33,9 @@ function hook(snapshot: T) { const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, } -const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, - recentWorkspaceId: items[0]?.workspaceId, +const noPendingInteraction: SessionPendingInteractionSnapshot = new Map() +const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({ + items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, }) function anchor(): { current: HTMLElement } { const element = document.createElement('button') @@ -91,6 +94,7 @@ function mount( open anchorRef={anchorRef} useSessions={hook(sessions)} + useSessionPendingInteraction={hook(noPendingInteraction)} useWorkspaces={hook(workspaceState(nextItems))} onPick={onPick} onClose={onClose} @@ -211,6 +215,7 @@ describe('WorkspacePicker', () => { render( , @@ -219,13 +224,14 @@ describe('WorkspacePicker', () => { }) it('keeps the menu up while the list baseline is still in flight', () => { - const state: WorkspaceListState = { - ...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false, + const state: WorkspaceSnapshot = { + ...workspaceState([]), phase: 'pending', state: 'loading', } const { renderSlot } = flowProbe() render( , diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts new file mode 100644 index 0000000000..7c128e187e --- /dev/null +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -0,0 +1,488 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + ISessions, SessionListState, SessionSummary, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { + IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, +} from '@deepseek-ai/dsh-api-workspace-controller/client' +import { + RpcId, + type DirectoryListing, + type IApiClient, + type RpcError, + type RpcResponse, +} from '@deepseek-ai/dsh-client-connection/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { DirectoryBrowseError } from '../src/client/index.ts' +import { UiWorkspaceService } from '../src/client/navigation.ts' + +const sid = (id: string): SessionId => SessionId(id) +const wid = (id: string): WorkspaceId => id as WorkspaceId + +afterEach(() => { + vi.restoreAllMocks() +}) + +function workspace( + id: string, + sessionIds: readonly SessionId[] = [], + createdAt = '2026-01-01T00:00:00.000Z', +): WorkspaceView { + return { + workspaceId: wid(id), + path: `/w/${id}`, + title: id, + sessionIds, + createdAt, + updatedAt: createdAt, + } +} + +function summary(id: string, overrides: Partial = {}): SessionSummary { + return { + id: sid(id), + displayTitle: id, + running: false, + blank: false, + updatedAt: 0, + ...overrides, + } +} + +function sessionState( + summaries: readonly SessionSummary[] = [], + current?: SessionId, + phase: SessionListState['phase'] = 'ready', +): SessionListState { + return { + ids: summaries.map(item => item.id), + byId: Object.fromEntries(summaries.map(item => [item.id, item])), + current, + phase, + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + } +} + +function workspaceState( + items: WorkspaceSnapshot['items'] = [], + archivedSessionIds: readonly SessionId[] = [], + phase: WorkspaceSnapshot['phase'] = 'ready', +): WorkspaceSnapshot { + return { + items, + archivedSessionIds, + phase, + state: phase === 'ready' ? 'idle' : 'loading', + error: null, + } +} + +class MutableSource { + private readonly listeners = new Set<() => void>() + + constructor(private value: T) {} + + getSnapshot(): T { + return this.value + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + set(value: T): void { + this.value = value + for (const listener of [...this.listeners]) listener() + } + + update(update: (value: T) => T): void { + this.set(update(this.value)) + } + + listenersSnapshot(): readonly (() => void)[] { + return [...this.listeners] + } +} + +class FakeSessions { + readonly list: MutableSource + readonly create: ReturnType> + readonly open: ReturnType void>> + readonly clear: ReturnType void>> + + constructor(initial: SessionListState) { + this.list = new MutableSource(initial) + this.create = vi.fn(async options => + options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`)) + this.open = vi.fn((id: SessionId) => { + this.list.update(state => ({ ...state, current: id })) + }) + this.clear = vi.fn(() => { + this.list.update(state => ({ ...state, current: undefined })) + }) + } +} + +class FakeWorkspaces implements IWorkspaces { + readonly list: MutableSource + readonly archiveCalls: SessionId[] = [] + onArchive: IWorkspaces['archiveSession'] = async (sessionId) => { + this.list.update(state => ({ + ...state, + archivedSessionIds: [...state.archivedSessionIds, sessionId], + })) + } + + declare readonly create: IWorkspaces['create'] + declare readonly rename: IWorkspaces['rename'] + declare readonly delete: IWorkspaces['delete'] + declare readonly insertBefore: IWorkspaces['insertBefore'] + declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore'] + + constructor(initial: WorkspaceSnapshot) { + this.list = new MutableSource(initial) + } + + archiveSession(sessionId: SessionId): Promise { + this.archiveCalls.push(sessionId) + return this.onArchive(sessionId) + } +} + +let nextRpcId = 0 + +function ok(value: T): RpcResponse { + return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: true, value } } +} + +function failed(error: RpcError): RpcResponse { + return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: false, error } } +} + +const listing: DirectoryListing = { + path: '/home/u', + home: '/home/u', + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'project', path: '/home/u/project', hidden: false }], + truncated: false, +} + +class FakeApiClient implements IApiClient { + readonly calls: Array<{ readonly method: string; readonly payload: unknown }> = [] + + onDescribe: IApiClient['host']['describe'] = () => Promise.resolve(ok({ + version: 'test', + cwd: '/home/u', + attachedSessions: 0, + home: '/home/u', + canOpenPath: true, + })) + onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null })) + onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing)) + onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' })) + onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true })) + + declare readonly subagents: IApiClient['subagents'] + declare readonly skills: IApiClient['skills'] + declare readonly agentPresets: IApiClient['agentPresets'] + declare readonly goals: IApiClient['goals'] + declare readonly settings: IApiClient['settings'] + declare readonly credentials: IApiClient['credentials'] + declare readonly llm: IApiClient['llm'] + + readonly host: IApiClient['host'] = { + describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)), + pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)), + listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)), + createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)), + openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)), + } + + callsOf(method: string): unknown[] { + return this.calls.filter(call => call.method === method).map(call => call.payload) + } + + private record(method: string, payload: unknown, response: Promise): Promise { + this.calls.push({ method, payload }) + return response + } +} + +interface BenchOptions { + readonly workspaces?: WorkspaceSnapshot + readonly sessions?: SessionListState +} + +function bench(options: BenchOptions = {}) { + const ctx = new Context() + const api = new FakeApiClient() + const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) + const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending')) + const uiWorkspace = new UiWorkspaceService( + ctx, + api, + workspaces, + sessions as unknown as ISessions, + ) + return { api, ctx, sessions, uiWorkspace, workspaces } +} + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('UiWorkspaceService', () => { + it('reuses only an unarchived member blank and coalesces concurrent creation', async () => { + const b = bench() + const memberBlank = sid('member-blank') + const archivedBlank = sid('archived-blank') + const summaries: readonly SessionSummary[] = [ + summary('stray', { blank: true, cwd: '/w/alpha' }), + summary('member-blank', { blank: true, cwd: '/w/alpha' }), + summary('active', { cwd: '/w/beta' }), + summary('archived-blank', { blank: true, cwd: '/w/gamma' }), + ] + b.workspaces.list.set(workspaceState([ + workspace('alpha', [memberBlank]), + workspace('beta', [sid('active')]), + workspace('gamma', [archivedBlank]), + ], [archivedBlank])) + b.sessions.list.set({ + ...sessionState(summaries, memberBlank), + ids: [sid('missing'), ...summaries.map(item => item.id)], + }) + + await expect(Promise.all([ + b.uiWorkspace.connectWorkspace(wid('alpha')), + b.uiWorkspace.connectWorkspace(wid('alpha')), + ])).resolves.toEqual([memberBlank, memberBlank]) + expect(b.sessions.create).not.toHaveBeenCalled() + + const creation = Promise.withResolvers() + b.sessions.create.mockImplementation(() => creation.promise) + const first = b.uiWorkspace.connectWorkspace(wid('beta')) + const second = b.uiWorkspace.connectWorkspace(wid('beta')) + expect(b.sessions.create).toHaveBeenCalledTimes(1) + creation.resolve(sid('fresh-beta')) + await expect(Promise.all([first, second])).resolves.toEqual([sid('fresh-beta'), sid('fresh-beta')]) + + b.sessions.create.mockImplementation(async options => sid(`fresh-${String(options?.workspaceId)}`)) + await expect(b.uiWorkspace.connectWorkspace(wid('gamma'))).resolves.toBe(sid('fresh-gamma')) + expect(b.sessions.create).toHaveBeenLastCalledWith({ workspaceId: wid('gamma') }) + await expect(b.uiWorkspace.connectWorkspace(wid('ghost'))) + .rejects.toThrow('uiWorkspace.connectWorkspace: unknown workspace ghost') + }) + + it('targets an explicit, current-session, then recent Workspace and reports failed starts', async () => { + const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 }) + const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 }) + const b = bench({ + sessions: sessionState([current, recent], current.id), + workspaces: workspaceState([ + workspace('current-home', [current.id]), + workspace('recent-home', [recent.id]), + ]), + }) + b.sessions.create.mockImplementation(async options => sid(`opened-${String(options?.workspaceId)}`)) + + b.uiWorkspace.startSession(wid('recent-home')) + await vi.waitFor(() => { + expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home')) + }) + + b.sessions.open(current.id) + b.uiWorkspace.startSession() + await vi.waitFor(() => { + expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-current-home')) + }) + + b.sessions.clear() + b.uiWorkspace.startSession() + await vi.waitFor(() => { + expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home')) + }) + + const empty = bench() + empty.uiWorkspace.startSession() + expect(empty.sessions.clear).toHaveBeenCalledOnce() + + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + b.sessions.create.mockRejectedValueOnce(new Error('create failed')) + b.uiWorkspace.startSession(wid('recent-home')) + await vi.waitFor(() => { + expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error)) + }) + }) + + it('opens the recent Workspace after both baselines arrive', async () => { + const b = bench() + b.sessions.create.mockResolvedValue(sid('initial')) + + const stableFirst = workspace('stable-first', [], '2026-01-01T00:00:00.000Z') + const recent = workspace('recent', [], '2026-01-02T00:00:00.000Z') + b.workspaces.list.set(workspaceState([stableFirst, recent])) + expect(b.sessions.create).not.toHaveBeenCalled() + b.sessions.list.set(sessionState()) + + await vi.waitFor(() => { + expect(b.sessions.open).toHaveBeenCalledWith(sid('initial')) + }) + expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('recent') }) + expect(b.workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual([ + wid('stable-first'), wid('recent'), + ]) + }) + + it('uses Workspace creation time when members are absent and preserves Host tie order', async () => { + const b = bench() + b.sessions.create.mockResolvedValue(sid('initial')) + + b.workspaces.list.set(workspaceState([ + workspace('newest', [sid('missing')], '2026-03-01T00:00:00.000Z'), + workspace('same-time', [], '2026-03-01T00:00:00.000Z'), + workspace('older', [], '2026-01-01T00:00:00.000Z'), + ])) + b.sessions.list.set(sessionState()) + + await vi.waitFor(() => { + expect(b.sessions.open).toHaveBeenCalledWith(sid('initial')) + }) + expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newest') }) + }) + + it('retries failed initial selection and never overwrites a later selection', async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const b = bench() + let attempts = 0 + b.sessions.create.mockImplementation(() => ++attempts === 1 + ? Promise.reject(new Error('attach exploded')) + : Promise.resolve(sid('retry'))) + b.workspaces.list.set(workspaceState([workspace('recent')])) + b.sessions.list.set(sessionState()) + await vi.waitFor(() => { + expect(warning).toHaveBeenCalledWith('initial workspace selection failed:', expect.any(Error)) + }) + b.workspaces.list.update(state => ({ ...state, items: [...state.items] })) + await vi.waitFor(() => { + expect(b.sessions.open).toHaveBeenCalledWith(sid('retry')) + }) + expect(attempts).toBe(2) + + const changed = bench() + const pending = Promise.withResolvers() + changed.sessions.create.mockImplementation(() => pending.promise) + changed.workspaces.list.set(workspaceState([workspace('recent')])) + changed.sessions.list.set(sessionState()) + await vi.waitFor(() => { expect(changed.sessions.create).toHaveBeenCalledOnce() }) + changed.sessions.open(sid('manual')) + pending.resolve(sid('automatic')) + await flush() + expect(changed.sessions.open).toHaveBeenCalledTimes(1) + expect(changed.sessions.open).toHaveBeenCalledWith(sid('manual')) + }) + + it('stops initial navigation when its Cordis lifetime is disposed', async () => { + const success = bench() + const resolved = Promise.withResolvers() + success.sessions.create.mockImplementation(() => resolved.promise) + success.workspaces.list.set(workspaceState([workspace('recent')])) + success.sessions.list.set(sessionState()) + await vi.waitFor(() => { expect(success.sessions.create).toHaveBeenCalledOnce() }) + await success.ctx.fiber.dispose() + resolved.resolve(sid('late')) + await flush() + expect(success.sessions.open).not.toHaveBeenCalled() + success.workspaces.list.set(workspaceState([workspace('ignored')])) + expect(success.sessions.create).toHaveBeenCalledOnce() + + const failure = bench() + const rejected = Promise.withResolvers() + failure.sessions.create.mockImplementation(() => rejected.promise) + failure.workspaces.list.set(workspaceState([workspace('recent')])) + failure.sessions.list.set(sessionState()) + await vi.waitFor(() => { expect(failure.sessions.create).toHaveBeenCalledOnce() }) + const staleReconciles = failure.workspaces.list.listenersSnapshot() + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await failure.ctx.fiber.dispose() + rejected.reject(new Error('late failure')) + await flush() + for (const reconcile of staleReconciles) reconcile() + expect(warning).not.toHaveBeenCalled() + expect(failure.sessions.create).toHaveBeenCalledOnce() + }) + + it('clears a current Session only after it enters the archive baseline', () => { + const current = summary('current') + const idle = summary('idle') + const b = bench({ + sessions: sessionState([current, idle], current.id), + workspaces: workspaceState([workspace('one', [current.id, idle.id])]), + }) + + b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] })) + expect(b.sessions.clear).not.toHaveBeenCalled() + b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [current.id] })) + expect(b.sessions.clear).toHaveBeenCalledOnce() + + b.sessions.open(idle.id) + b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] })) + expect(b.sessions.clear).toHaveBeenCalledTimes(2) + + const archived = bench({ + sessions: sessionState([current], current.id), + workspaces: workspaceState([workspace('one', [current.id])], [current.id]), + }) + expect(archived.sessions.clear).toHaveBeenCalledOnce() + }) + + it('forwards archive commands and preserves failures', async () => { + const idle = sid('idle') + const b = bench() + + await b.uiWorkspace.archiveSession(idle) + expect(b.workspaces.archiveCalls).toEqual([idle]) + + b.workspaces.onArchive = () => Promise.reject(new Error('archive rejected')) + await expect(b.uiWorkspace.archiveSession(idle)).rejects.toThrow('archive rejected') + expect(b.workspaces.archiveCalls).toEqual([idle, idle]) + }) + + it('passes directory operations to the Host and preserves structured browse failures', async () => { + const b = bench() + b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) + await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha') + b.api.onPickDirectory = () => Promise.resolve(ok({ path: null })) + await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull() + expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}]) + + await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing) + await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing) + expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }]) + await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new') + expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) + await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined() + expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }]) + + b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} })) + await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser') + b.api.onListDirectory = () => Promise.resolve(failed({ + code: 'directory-unreadable', message: 'denied', details: { path: '/private' }, + })) + const listFailure = b.uiWorkspace.listDirectory('/private') + await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) + await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) + b.api.onCreateDirectory = () => Promise.resolve(failed({ + code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' }, + })) + await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({ + rpcError: { code: 'directory-exists' }, + }) + b.api.onOpenPath = () => Promise.resolve(failed({ code: 'internal', message: 'boom', details: {} })) + await expect(b.uiWorkspace.openPath('/missing')).rejects.toThrow('path open failed: boom') + }) +}) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index e419270fb8..80bc10dc0d 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -8,6 +8,12 @@ "src" ], "references": [ + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../api/workspace-controller/tsconfig.client.json" + }, { "path": "../locale" }, @@ -24,7 +30,10 @@ "path": "../connection/tsconfig.client.json" }, { - "path": "../runtime" + "path": "../store" + }, + { + "path": "../../core/session" }, { "path": "../ui-sidebar" @@ -32,6 +41,12 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/client/web/package.json b/packages/client/web/package.json index c7faf9d701..9083a5bc21 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -29,6 +29,7 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/web/src/boot.ts b/packages/client/web/src/boot.ts index c0aa6ad90f..71ab65c140 100644 --- a/packages/client/web/src/boot.ts +++ b/packages/client/web/src/boot.ts @@ -100,15 +100,8 @@ export class AppWebEntry { await mounted } - /** Prefetch stage-one bundles; their import path owns any eventual failure. */ + /** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */ private async prefetchImmediateTier(): Promise { - // A transport carrying loadBundle owns the bundle bytes; HTTP prefetch - // against its static deployment answers nothing. A transport without - // loadBundle leaves bundles on HTTP, prefetch included. - const transport = (globalThis as { - __DSH_TRANSPORT__?: { loadBundle?: unknown } - }).__DSH_TRANSPORT__ - if (transport?.loadBundle !== undefined) return await Promise.all(this.manifest.plugins .filter(row => row.immediately) .map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => { diff --git a/packages/client/web/src/platform.ts b/packages/client/web/src/platform.ts index 6b946869f8..197776cb6d 100644 --- a/packages/client/web/src/platform.ts +++ b/packages/client/web/src/platform.ts @@ -7,13 +7,13 @@ /** The module specifiers the shell shares into the frozen module table. */ export const PLATFORM_MODULES = [ 'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/cordis', + '@deepseek-ai/dsh-client-store', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-ui-primitives', ] as const /** Client-bundle specifiers whose factories the parser preloads before the shell starts. */ export const PRELOADED_CLIENT_EXTERNALS = [ - '@deepseek-ai/dsh-client-runtime/client', ] as const /** One platform module specifier (a seed-table key). */ diff --git a/packages/client/web/src/seed.ts b/packages/client/web/src/seed.ts index c95f350561..1f213641ca 100644 --- a/packages/client/web/src/seed.ts +++ b/packages/client/web/src/seed.ts @@ -11,6 +11,7 @@ import * as ReactJsxRuntime from 'react/jsx-runtime' import * as ReactDom from 'react-dom' import * as ReactDomClient from 'react-dom/client' import * as Cordis from '@deepseek-ai/cordis' +import * as ClientStore from '@deepseek-ai/dsh-client-store' import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots' import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import type { PlatformModule } from './platform.ts' @@ -29,6 +30,7 @@ export function getStaticModules(): Record { 'react-dom': ReactDom, 'react-dom/client': ReactDomClient, '@deepseek-ai/cordis': Cordis, + '@deepseek-ai/dsh-client-store': ClientStore, '@deepseek-ai/dsh-client-ui-slots': UiSlots, '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives, } satisfies Record diff --git a/packages/client/web/tests/boot.client.spec.ts b/packages/client/web/tests/boot.client.spec.ts index 006ad0096c..def708d2c5 100644 --- a/packages/client/web/tests/boot.client.spec.ts +++ b/packages/client/web/tests/boot.client.spec.ts @@ -9,13 +9,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AppWebEntry } from '../src/boot.ts' const MODULES_ID = '@deepseek-ai/dsh-client-modules' +const PROVIDER_CLIENT_ID = 'provider/client' +const RUNTIME_CLIENT_ID = 'runtime/client' const win = globalThis as DshWindow +const transportGlobal = globalThis as { + __DSH_TRANSPORT__?: { loadBundle(url: string): Promise } +} const moduleFace = modulesClient as unknown as Record afterEach(() => { vi.restoreAllMocks() delete win.__DSH_BOOT__ delete win.__ModuleLoader__ + delete transportGlobal.__DSH_TRANSPORT__ document.body.innerHTML = '' }) @@ -80,6 +86,69 @@ describe('bootstrap failure rendering', () => { }) describe('plugin activation', () => { + it('prefetches a parser-loaded immediate row through the injected bundle transport', async () => { + const container = document.createElement('div') + document.body.append(container) + const target = installFacade() + const entries: WebBootEntry[] = [ + { id: 'consumer', url: '/consumer.js', rev: '1' }, + { + id: 'runtime', + url: '/runtime.js', + rev: '1', + external: [PROVIDER_CLIENT_ID], + immediately: true, + }, + { id: 'provider', url: '/provider.js', rev: '1' }, + { id: 'renderer', url: '/renderer.js', rev: '1' }, + ] + win.__DSH_BOOT__ = { rev: 'graph', entries } + target.load({ + id: 'runtime', + factory: require => ({ + apply: () => {}, + marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker, + }), + }) + const loaded: string[] = [] + const registrations = new Map([ + ['/consumer.js', { + id: 'consumer', + factory: require => ({ + apply: () => { + expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider') + }, + }), + }], + ['/provider.js', { + id: 'provider', + factory: () => ({ apply: () => {}, marker: 'provider' }), + }], + ['/renderer.js', { + id: 'renderer', + factory: () => ({ + apply: (ctx: Context) => { + ctx.reflect.provide('uiRenderer', { mount: () => () => {} }) + }, + }), + }], + ]) + transportGlobal.__DSH_TRANSPORT__ = { + loadBundle: async (url) => { + loaded.push(url) + const registration = registrations.get(url) + if (registration === undefined) throw new Error(`missing fixture registration ${url}`) + target.load(registration) + }, + } + + const entry = new AppWebEntry(container) + await entry.run() + + expect(loaded).toEqual(['/provider.js', '/consumer.js', '/renderer.js']) + await entry.dispose() + }) + it('allows a modules-dependent row to be created before the modules row', async () => { const events: string[] = [] const container = document.createElement('div') diff --git a/packages/client/web/tsconfig.json b/packages/client/web/tsconfig.json index e31dfe1fa7..5ee451203d 100644 --- a/packages/client/web/tsconfig.json +++ b/packages/client/web/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../store" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index f95582851d..03fd952ac5 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -10,7 +10,7 @@ import type { Context, Events } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' -import type { Agent } from './runtime-types.ts' +import type { Agent } from './types.ts' /** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */ type Params = F extends (...args: infer P) => unknown ? P : never diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 81052096dc..5334728416 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -12,8 +12,8 @@ import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol' -import type { Agent, AgentOptions } from './runtime-types.ts' +import type { Agent } from './types.ts' +import type { AgentOptions } from './runtime-types.ts' export * from './runtime-types.ts' export * from './types.ts' @@ -23,16 +23,6 @@ export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' -declare module '@deepseek-ai/dsh-typert-protocol' { - interface TypertLookupMap { - agent: TypertLookup - } - - interface TypertContextMap { - agent: TypertContext - } -} - declare module '@deepseek-ai/cordis' { interface Context { agents: AgentRegistry @@ -276,6 +266,7 @@ export class AgentRegistry extends Service { typeCtx.typert.contexts.registerHost('agent', { wire: 'agentId', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + identity: candidate => candidate.agent?.id, resolve: sessionId => this.get(sessionId)?.ctx, }) }) diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 7d713f8c77..df7449d406 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -8,10 +8,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' -import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { AgentCancelCause, Session, UserMessage } from '@deepseek-ai/dsh-session' export type { AgentCancelCause } from '@deepseek-ai/dsh-session' import type { Inbox } from './inbox.ts' -import type { InboxTarget } from './types.ts' +import type { Agent } from './types.ts' +export type { Agent } from './types.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -60,39 +61,37 @@ export type RequestErrorAction = { kind: 'retry' } | undefined /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' -/** Public live-agent handle. */ -export interface Agent { - /** The single identity shared with {@link session}. */ - readonly id: SessionId - /** The provider route and model this agent's requests use. */ - readonly options: AgentOptions - /** The live session this agent drives; its log is the durable source of truth. */ - readonly session: Session - /** The agent-owned projection of durable pending work. */ - readonly inbox: Inbox - /** The current lifecycle state, mirrored on every `agent/status` transition. */ - readonly status: AgentStatus - /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - readonly ctx: Context +declare module './types.ts' { + interface Agent { + /** The provider route and model this agent's requests use. */ + readonly options: AgentOptions + /** The live session this agent drives; its log is the durable source of truth. */ + readonly session: Session + /** The agent-owned projection of durable pending work. */ + readonly inbox: Inbox + /** The current lifecycle state, mirrored on every `agent/status` transition. */ + readonly status: AgentStatus + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ + readonly ctx: Context - /** + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn or between-turn task. The first cause wins for that activity. With no * active activity, cancellation is a no-op and does not arm later work. * @param cause - the stable caller intent carried by the active operation signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - cancel(cause: AgentCancelCause, options?: CancelOptions): void + cancel(cause: AgentCancelCause, options?: CancelOptions): void - /** + /** * Resolve after the current whole-agent activity reaches quiescence. This * follows replacement work started before the observed driver retires, * but does not identify the settlement of any particular message. * @returns fulfillment after no active driver or maintenance task remains. */ - whenIdle(): Promise + whenIdle(): Promise - /** + /** * Run one non-turn maintenance task from the true idle phase. The task starts * synchronously after claiming that phase; later waking input remains in the * inbox until the task settles, while public status stays `idle`. @@ -101,9 +100,9 @@ export interface Agent { * @throws synchronously when turn-driving or another maintenance task already owns the agent. * @returns the task promise. */ - runMaintenance(task: (signal: AbortSignal) => Promise): Promise + runMaintenance(task: (signal: AbortSignal) => Promise): Promise - /** + /** * Route identified input to an inbox boundary and optionally wake the driver. * Waking input submitted after active cancellation is queued for the next * turn and runs when the aborted activity converges to idle; a `disposed` @@ -114,25 +113,25 @@ export interface Agent { * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. */ - send(message: UserMessage, target: InboxTarget, wakeup: boolean): void + send(message: UserMessage, target: InboxTarget, wakeup: boolean): void - /** + /** * Queue an ordinary follow-up turn and wake the driver. The item becomes the * sole ordinary message of its own turn. * @param message - identified prompt content and the source that supplied it. */ - followup(message: UserMessage): void + followup(message: UserMessage): void - /** + /** * Submit steering for the nearest step. An idle driver starts a turn; * a running driver consumes it at its next step boundary. * A rejected step leaves steering parked in the inbox until the next * wake; cancellation or disposal may discard pending steering. * @param message - identified steering content and the source that supplied it. */ - steer(message: UserMessage): void + steer(message: UserMessage): void - /** + /** * Queue model-facing context for the next pre-step without waking the * driver. A running driver claims it at the nearest later step boundary; * idle drivers leave it pending until follow-up or steering @@ -140,7 +139,8 @@ export interface Agent { * batch. Cancellation or disposal may discard pending context. * @param message - identified injected context and the source that supplied it. */ - inject(message: UserMessage): void + inject(message: UserMessage): void + } } declare module '@deepseek-ai/cordis' { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b54e56ea8f..549af341db 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -5,6 +5,25 @@ */ import type { UserMessage } from '@deepseek-ai/dsh-llm/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol' + +/** Public live-agent handle; the runtime face augments its live capabilities. */ +export interface Agent { + /** Session-backed Agent identity. */ + readonly id: SessionId +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertLookupMap { + agent: TypertLookup + } + + interface TypertContextMap { + /** Agent Context identity shared by Host and Client adapters. */ + agent: TypertContext + } +} /** One of the two ordered pending-message lists owned by an agent. */ export type InboxTarget = 'next-turn' | 'next-step' diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 4876e38809..33ecfb3c7b 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -149,6 +149,7 @@ describe('AgentRegistry', () => { await agentFiber await ctx.plugin(TypertRegistry) const agent = stubAgent('remote-agent') + Object.defineProperty(agent, 'ctx', { value: agent.ctx.extend({ agent }) }) const disposeAgent = ctx.agents.register(agent) const lookup = ctx.typert.lookups.get('agent') @@ -159,7 +160,10 @@ describe('AgentRegistry', () => { wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', }) expect(lookup?.resolve(agent.id)).toBe(agent) - expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx) + const context = ctx.typert.contexts.getHost('agent') + expect(context?.identity(agent.ctx)).toBe(agent.id) + expect(context?.identity(ctx)).toBeUndefined() + expect(context?.resolve(agent.id)).toBe(agent.ctx) disposeAgent() expect(lookup?.resolve(agent.id)).toBeUndefined() diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 672914c5c3..da93075512 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -34,6 +34,7 @@ const scopedSubjectResolvers: Readonly (args[0] as Record)['agent'], 'tools/pre-execute': args => (args[0] as Record)['agent'], 'tools/result': args => (args[0] as Record)['agent'], + 'user-questions/request': args => (args[0] as Record)['agent'], }) /** diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 345766b042..96c1488ea2 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -79,6 +79,7 @@ describe('scoped-dispatch invariants', () => { ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]], + ['user-questions/request', [{ agent, questions: [] }, () => Promise.resolve({ answers: [] })]], ] for (const [event, args] of rows) { diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 3fdcaa3419..c083f13fc1 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -48,6 +48,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", diff --git a/packages/experimental/webworker-runtime/src/client/api-client.ts b/packages/experimental/webworker-runtime/src/client/api-client.ts index 0e99075d47..17bbc8f270 100644 --- a/packages/experimental/webworker-runtime/src/client/api-client.ts +++ b/packages/experimental/webworker-runtime/src/client/api-client.ts @@ -1,9 +1,7 @@ /** - * Page-side API carrier over the postMessage tunnel. Only `doFetch` is - * implemented: the streaming methods stay on `AbstractApiClient`'s default - * `readSse`, which is exactly what the worker answers on the two event-stream - * paths — so unary calls and downstream streams share one framing and neither - * side needs a WebSocket. + * Page-side unary API carrier over the postMessage tunnel. Gateway Remote + * streams use the tunnel's dedicated logical-stream frames instead of this + * fetch-shaped API path. */ import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' import type { WorkerTunnel } from './client.ts' diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index e03fb6e9c9..7fa05a5355 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -6,31 +6,16 @@ */ import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' - -/** Frame sent to the worker. */ -interface RequestFrame { - t: 'req' - id: number - method: string - /** Absolute URL; the worker derives `req.url` (pathname + search) from it. */ - url: string - headers: Record - body?: ArrayBuffer | undefined -} - -/** Cancellation of an in-flight request or stream. */ -interface AbortFrame { - t: 'abort' - id: number -} - -/** Frames received from the worker. */ -type ResponseFrame = - | { t: 'res'; id: number; status: number; headers: Record; body?: ArrayBuffer; message?: string } - | { t: 'res-head'; id: number; status: number; headers: Record } - | { t: 'res-chunk'; id: number; chunk: ArrayBuffer } - | { t: 'res-end'; id: number } - | { t: 'res-err'; id: number; message: string } +import type { + TunnelAbortFrame as AbortFrame, + TunnelOutboundFrame as ResponseFrame, + TunnelRequestFrame as RequestFrame, + TunnelRequestId, + TunnelStreamEndFrame, + TunnelStreamErrorFrame, + TunnelStreamItemFrame, + TunnelStreamOpenFrame, +} from '../transport/frames.ts' /** Boot payload of the tunnel bootstrap route. */ export interface BootPayload { @@ -46,6 +31,58 @@ interface PendingUnary { reject(reason: Error): void } +type LogicalStreamFrame = TunnelStreamItemFrame | TunnelStreamEndFrame | TunnelStreamErrorFrame + +interface TunnelStreamFailureMarker { + readonly kind: 'remote' | 'carrier' + readonly code?: string + readonly details?: object +} + +/** Error carrying stream semantics across independently bundled Client code. */ +class TunnelLogicalStreamError extends Error { + readonly dshRemoteStreamFailure: TunnelStreamFailureMarker + + constructor(failure: TunnelStreamErrorFrame['failure'], options?: ErrorOptions) { + super(failure.message, options) + this.name = 'TunnelLogicalStreamError' + this.dshRemoteStreamFailure = failure.kind === 'remote' + ? { kind: 'remote', code: failure.code, details: failure.details } + : { kind: 'carrier' } + } +} + +class LogicalStreamInbox { + private readonly frames: LogicalStreamFrame[] = [] + private wake: (() => void) | undefined + private failed = false + private failure: unknown + + push(frame: LogicalStreamFrame): void { + if (this.failed) return + this.frames.push(frame) + this.wake?.() + this.wake = undefined + } + + fail(reason: unknown): void { + if (this.failed) return + this.failed = true + this.failure = reason + this.frames.length = 0 + this.wake?.() + this.wake = undefined + } + + async next(): Promise { + while (this.frames.length === 0) { + if (this.failed) throw this.failure + await new Promise((resolve) => { this.wake = resolve }) + } + return this.frames.shift() as LogicalStreamFrame + } +} + /** * Statuses the worker only produces when the host refused the exchange rather than * answered it; a route's own 4xx is the tree talking and stays silent here. @@ -72,8 +109,9 @@ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]) export class WorkerTunnel { private readonly worker: Worker private nextId = 1 - private readonly unary = new Map() - private readonly streams = new Map>() + private readonly unary = new Map() + private readonly bodyStreams = new Map>() + private readonly logicalStreams = new Map() /** * In-flight request descriptions, so a refusal names what was refused. * @@ -82,10 +120,10 @@ export class WorkerTunnel { * page console but not the frames. Warning here separates the two without * recording anything on the normal path, where no refusal frame ever arrives. */ - private readonly inFlight = new Map() + private readonly inFlight = new Map() /** Body-phase abort listeners, released when their stream settles. */ - private readonly releases = new Map void>() + private readonly releases = new Map void>() /** * Attach to a spawned worker and start consuming response frames. @@ -102,8 +140,14 @@ export class WorkerTunnel { this.inFlight.clear() for (const pending of this.unary.values()) pending.reject(reason) this.unary.clear() - for (const controller of this.streams.values()) controller.error(reason) - this.streams.clear() + for (const controller of this.bodyStreams.values()) controller.error(reason) + this.bodyStreams.clear() + const failure = new TunnelLogicalStreamError({ + kind: 'carrier', + message: `web-preview tunnel: worker failed: ${event.message}`, + }, { cause: reason }) + for (const inbox of this.logicalStreams.values()) inbox.fail(failure) + this.logicalStreams.clear() for (const release of this.releases.values()) release() this.releases.clear() }) @@ -145,13 +189,60 @@ export class WorkerTunnel { const settled = await Promise.race([response, raced.rejected]) // A streaming response outlives its head: hand the signal to the body // phase, so a later stop still ends the stream and reaches the worker. - if (this.streams.has(id)) this.observeStreamAbort(id, signal) + if (this.bodyStreams.has(id)) this.observeStreamAbort(id, signal) return settled } finally { raced.release() } } + /** + * Open one decoded Gateway Remote stream over the worker-local carrier. + * @param endpoint - canonical Gateway Remote endpoint. + * @param payload - decoded endpoint payload. + * @param signal - logical-stream cancellation. + * @returns decoded stream values from the worker Host. + */ + async *open(endpoint: string, payload: unknown, signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const id = this.nextId++ + const inbox = new LogicalStreamInbox() + let opened = false + let terminal = false + const onAbort = (): void => { inbox.fail(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) + this.logicalStreams.set(id, inbox) + this.inFlight.set(id, `STREAM ${endpoint}`) + try { + const frame: TunnelStreamOpenFrame = { t: 'stream-open', id, endpoint, payload } + try { + this.worker.postMessage(frame) + opened = true + } catch (cause) { + throw new TunnelLogicalStreamError({ + kind: 'carrier', + message: `web-preview tunnel: failed to open Remote stream ${endpoint}`, + }, { cause }) + } + while (true) { + const response = await inbox.next() + signal.throwIfAborted() + if (response.t === 'stream-item') { + yield response.value + continue + } + terminal = true + if (response.t === 'stream-error') throw new TunnelLogicalStreamError(response.failure) + return + } + } finally { + signal.removeEventListener('abort', onAbort) + this.logicalStreams.delete(id) + this.inFlight.delete(id) + if (opened && !terminal) this.abortWorkerOperation(id) + } + } + /** * Read the pre-cordis boot payload (the injection table). * @returns The payload the page applies before the client tree loads. @@ -198,7 +289,7 @@ export class WorkerTunnel { } } - private rejectOnAbort(id: number, signal: AbortSignal): { rejected: Promise; release: () => void } { + private rejectOnAbort(id: TunnelRequestId, signal: AbortSignal): { rejected: Promise; release: () => void } { let release = (): void => {} const rejected = new Promise((_resolve, reject) => { const fail = (): void => { reject(this.abortRequest(id)) } @@ -220,14 +311,13 @@ export class WorkerTunnel { * @param id - request id being abandoned. * @returns The abort error the caller surfaces. */ - private abortRequest(id: number): DOMException { + private abortRequest(id: TunnelRequestId): DOMException { this.unary.delete(id) - const controller = this.streams.get(id) - this.streams.delete(id) + const controller = this.bodyStreams.get(id) + this.bodyStreams.delete(id) this.inFlight.delete(id) this.releases.delete(id) - const abort: AbortFrame = { t: 'abort', id } - this.worker.postMessage(abort) + this.abortWorkerOperation(id) const reason = new DOMException('The operation was aborted.', 'AbortError') controller?.error(reason) return reason @@ -240,26 +330,35 @@ export class WorkerTunnel { * @param id - request id whose body is still crossing. * @param signal - the caller's signal. */ - private observeStreamAbort(id: number, signal: AbortSignal): void { + private observeStreamAbort(id: TunnelRequestId, signal: AbortSignal): void { const onAbort = (): void => { this.abortRequest(id) } signal.addEventListener('abort', onAbort, { once: true }) this.releases.set(id, () => { signal.removeEventListener('abort', onAbort) }) } /** Release a body-phase abort listener a settled stream no longer needs. */ - private releaseSignal(id: number): void { + private releaseSignal(id: TunnelRequestId): void { const release = this.releases.get(id) this.releases.delete(id) release?.() } /** Cancel a stream the consumer stopped reading (the head already resolved). */ - private cancelStream(id: number): void { + private cancelStream(id: TunnelRequestId): void { this.releaseSignal(id) - this.streams.delete(id) + this.bodyStreams.delete(id) this.inFlight.delete(id) + this.abortWorkerOperation(id) + } + + /** Best-effort cancellation: a failed worker cannot receive the frame anyway. */ + private abortWorkerOperation(id: TunnelRequestId): void { const abort: AbortFrame = { t: 'abort', id } - this.worker.postMessage(abort) + try { + this.worker.postMessage(abort) + } catch { + // The operation is already locally terminal; worker failure is reported by its owning path. + } } /** @@ -271,7 +370,7 @@ export class WorkerTunnel { * @param id - request id the frame answers. * @param outcome - what came back instead of a reply. */ - private warnRefusal(id: number, outcome: string): void { + private warnRefusal(id: TunnelRequestId, outcome: string): void { console.warn(`web-preview tunnel: request ${String(id)} ${this.inFlight.get(id) ?? '(unknown request)'} → ${outcome}`) } @@ -297,7 +396,7 @@ export class WorkerTunnel { this.unary.delete(frame.id) const stream = new ReadableStream({ start: (controller) => { - this.streams.set(frame.id, controller) + this.bodyStreams.set(frame.id, controller) }, cancel: () => { this.cancelStream(frame.id) @@ -307,13 +406,13 @@ export class WorkerTunnel { return } case 'res-chunk': { - this.streams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk)) + this.bodyStreams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk)) return } case 'res-end': { - const controller = this.streams.get(frame.id) + const controller = this.bodyStreams.get(frame.id) if (controller === undefined) return - this.streams.delete(frame.id) + this.bodyStreams.delete(frame.id) this.inFlight.delete(frame.id) this.releaseSignal(frame.id) controller.close() @@ -329,13 +428,19 @@ export class WorkerTunnel { pending.reject(reason) return } - const controller = this.streams.get(frame.id) + const controller = this.bodyStreams.get(frame.id) if (controller === undefined) return - this.streams.delete(frame.id) + this.bodyStreams.delete(frame.id) this.releaseSignal(frame.id) controller.error(reason) return } + case 'stream-item': + case 'stream-end': + case 'stream-error': { + this.logicalStreams.get(frame.id)?.push(frame) + return + } default: { const unknown: never = frame throw new Error(`web-preview tunnel: unknown frame ${JSON.stringify(unknown)}`) diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 4603586fca..c84f637896 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -23,6 +23,7 @@ interface ClientTransportGlobal { __DSH_TRANSPORT__?: { createApiClient: () => WorkerApiClient fetch: TunnelFetch + openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable loadBundle: (url: string) => Promise /** The page spawned the worker the Host runs in, so the page owns it. */ ownsHost: boolean @@ -84,6 +85,7 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = { createApiClient: () => new WorkerApiClient(tunnel), fetch: (input, init) => tunnel.fetch(input, init), + openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal), loadBundle: (url: string) => tunnel.loadBundle(url), // The host lives in a worker this page spawned: the page owns it, so // the privileged surface stays reachable off loopback authorities. diff --git a/packages/experimental/webworker-runtime/src/index.ts b/packages/experimental/webworker-runtime/src/index.ts index 762384f1d6..cf39694ca0 100644 --- a/packages/experimental/webworker-runtime/src/index.ts +++ b/packages/experimental/webworker-runtime/src/index.ts @@ -11,6 +11,8 @@ export { type TunnelAbortFrame, type TunnelInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame, type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame, + type TunnelStreamEndFrame, type TunnelStreamErrorFrame, type TunnelStreamItemFrame, + type TunnelStreamOpenFrame, } from './transport/frames.ts' export { DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader, @@ -23,7 +25,7 @@ export { } from './transport/synthetic-http.ts' export { lowerModuleSource, type LoweredModule } from './compile/transform.ts' export { - API_PREFIX, STREAM_PATHS, SYNTHETIC_HOST, TunnelServer, + API_PREFIX, SYNTHETIC_HOST, TunnelServer, type TunnelPort, type TunnelSeams, type TunnelServerOptions, } from './transport/tunnel.ts' export { installProcessGlobal, type ProcessShim, type ProcessShimOptions } from './node/globals/process.ts' diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index e10f58a021..67d89b73fd 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -17,6 +17,14 @@ export interface TunnelRequestFrame { readonly body?: ArrayBuffer | undefined } +/** Open one Gateway Remote stream over the worker-local carrier. */ +export interface TunnelStreamOpenFrame { + readonly t: 'stream-open' + readonly id: TunnelRequestId + readonly endpoint: string + readonly payload: unknown +} + /** Page-side cancellation of an in-flight request or stream. */ export interface TunnelAbortFrame { readonly t: 'abort' @@ -34,7 +42,11 @@ export interface TunnelInitFrame { } /** Every frame the page sends the worker. */ -export type TunnelInboundFrame = TunnelInitFrame | TunnelRequestFrame | TunnelAbortFrame +export type TunnelInboundFrame = + | TunnelInitFrame + | TunnelRequestFrame + | TunnelStreamOpenFrame + | TunnelAbortFrame /** Complete response for unary requests and static files. */ export interface TunnelResponseFrame { @@ -75,6 +87,36 @@ export interface TunnelResponseErrorFrame { readonly message: string } +/** One decoded value from a worker-local Gateway Remote stream. */ +export interface TunnelStreamItemFrame { + readonly t: 'stream-item' + readonly id: TunnelRequestId + readonly value?: unknown +} + +/** Normal completion of a worker-local Gateway Remote stream. */ +export interface TunnelStreamEndFrame { + readonly t: 'stream-end' + readonly id: TunnelRequestId +} + +/** Stable Host failure or worker-carrier failure for one logical stream. */ +export interface TunnelStreamErrorFrame { + readonly t: 'stream-error' + readonly id: TunnelRequestId + readonly failure: + | { + readonly kind: 'remote' + readonly code: string + readonly message: string + readonly details: object + } + | { + readonly kind: 'carrier' + readonly message: string + } +} + /** Frames the worker emits. */ export type TunnelOutboundFrame = | TunnelResponseFrame @@ -82,6 +124,9 @@ export type TunnelOutboundFrame = | TunnelResponseChunkFrame | TunnelResponseEndFrame | TunnelResponseErrorFrame + | TunnelStreamItemFrame + | TunnelStreamEndFrame + | TunnelStreamErrorFrame /** * Validate a `postMessage` payload as a tunnel frame. @@ -104,6 +149,12 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { throw new Error(`webworker tunnel: frame has no usable id: ${JSON.stringify(frame.id)}`) } if (frame.t === 'abort') return { t: 'abort', id } + if (frame.t === 'stream-open') { + if (typeof frame.endpoint !== 'string' || frame.endpoint.length === 0) { + throw new Error(`webworker tunnel: stream ${String(id)} needs a non-empty endpoint`) + } + return { t: 'stream-open', id, endpoint: frame.endpoint, payload: frame.payload } + } if (frame.t !== 'req') throw new Error(`webworker tunnel: unknown frame type ${JSON.stringify(frame.t)}`) if (typeof frame.method !== 'string' || typeof frame.url !== 'string') { throw new Error(`webworker tunnel: request ${String(id)} needs string method and url`) diff --git a/packages/experimental/webworker-runtime/src/transport/tunnel.ts b/packages/experimental/webworker-runtime/src/transport/tunnel.ts index 47b6265288..b181e89687 100644 --- a/packages/experimental/webworker-runtime/src/transport/tunnel.ts +++ b/packages/experimental/webworker-runtime/src/transport/tunnel.ts @@ -4,8 +4,6 @@ * * - `GET /__boot__` answers from tunnel glue, never from the host API surface, * because the page needs the boot payload before its Cordis tree exists. - * - The two event-stream paths go straight to the API fetch handler so they take - * its SSE branch; the `/api` route answers 426 upgrade-required first. * - Privileged `/api` methods take that same direct entry: the browser strips the * `host` header from the WHATWG `Request` the route lane rebuilds, so the * privileged fence would answer 403 for every one of them. The method set is @@ -21,14 +19,12 @@ */ import { parseInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame, type TunnelRequestId, + type TunnelStreamOpenFrame, } from './frames.ts' import { createSyntheticExchange, type RequestListener, type ResponseSink, type SyntheticExchange, } from './synthetic-http.ts' -/** Event-stream routes that must reach the SSE branch of the API fetch handler. */ -export const STREAM_PATHS = ['/api/events.mux', '/api/events.host'] as const - /** Prefix owning the API methods. */ export const API_PREFIX = '/api' @@ -91,12 +87,24 @@ export interface TunnelPort { /** What the tunnel gains once the host tree is up. */ export interface TunnelSeams { /** - * Direct entry to the API fetch handler: event streams, privileged methods, - * and any unary call the route lane refused with 403. + * Direct entry to the API fetch handler for privileged methods and any unary + * call the route lane refused with 403. */ readonly directFetch: (request: Request) => Promise /** Boot payload for `GET /__boot__`: the structured index injection table. */ readonly bootPayload: () => unknown + /** Open one decoded Gateway Remote stream without another network carrier. */ + readonly openStream: ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ) => Promise> + /** Convert a Gateway stream failure to stable Client fields. */ + readonly streamFailure: (error: unknown) => { + readonly code: string + readonly message: string + readonly details: object + } } /** Construction inputs for {@link TunnelServer}. */ @@ -125,6 +133,8 @@ interface InFlight { abort(): void } +type QueuedFrame = TunnelRequestFrame | TunnelStreamOpenFrame + /** Recorded response frames, so a 403 from the route lane can be discarded. */ class BufferedSink { private readonly calls: Array<() => void> = [] @@ -171,7 +181,7 @@ export class TunnelServer { private readonly requestListener: () => Promise private readonly privilegedMethods: ReadonlySet | undefined private readonly unaryApiLane: 'route' | 'direct' - private readonly queue: TunnelRequestFrame[] = [] + private readonly queue: QueuedFrame[] = [] private readonly inFlight = new Map() private seams: TunnelSeams | undefined private failure: string | undefined @@ -209,7 +219,7 @@ export class TunnelServer { this.queue.push(frame) return } - void this.serveRequest(frame) + this.dispatchFrame(frame) } /** @@ -219,7 +229,7 @@ export class TunnelServer { serve(seams: TunnelSeams): void { this.seams = seams console.info(`webworker tunnel: serving (unary /api lane=${this.unaryApiLane}${this.unaryApiLane === 'route' ? ' with 403 retry' : ''}, privileged set=${this.privilegedMethods === undefined ? 'none' : String(this.privilegedMethods.size)}, queued=${String(this.queue.length)})`) - for (const frame of this.queue.splice(0)) void this.serveRequest(frame) + for (const frame of this.queue.splice(0)) this.dispatchFrame(frame) } /** @@ -237,7 +247,15 @@ export class TunnelServer { this.port.postMessage(frame, transfer) } - private refuse(frame: TunnelRequestFrame, message: string): void { + private refuse(frame: QueuedFrame, message: string): void { + if (frame.t === 'stream-open') { + this.send({ + t: 'stream-error', + id: frame.id, + failure: { kind: 'carrier', message }, + }) + return + } const body = toTransferable(encoder.encode(message)) this.send({ t: 'res', @@ -249,6 +267,40 @@ export class TunnelServer { }, [body]) } + private dispatchFrame(frame: QueuedFrame): void { + if (frame.t === 'stream-open') void this.serveStream(frame) + else void this.serveRequest(frame) + } + + private async serveStream(frame: TunnelStreamOpenFrame): Promise { + if (this.seams === undefined) { + this.refuse(frame, 'webworker tunnel: Remote stream requested before the host tree is serving') + return + } + const seams = this.seams + const controller = new AbortController() + this.inFlight.set(frame.id, { abort: () => { controller.abort() } }) + try { + const source = await seams.openStream(frame.endpoint, frame.payload, controller.signal) + for await (const value of source) { + if (controller.signal.aborted) return + this.send({ t: 'stream-item', id: frame.id, value }) + } + if (!controller.signal.aborted) this.send({ t: 'stream-end', id: frame.id }) + } catch (error) { + if (!controller.signal.aborted) { + const failure = seams.streamFailure(error) + this.send({ + t: 'stream-error', + id: frame.id, + failure: { kind: 'remote', ...failure }, + }) + } + } finally { + this.inFlight.delete(frame.id) + } + } + private sinkFor(id: TunnelRequestId): ResponseSink { const send = this.send.bind(this) const inFlight = this.inFlight @@ -291,7 +343,6 @@ export class TunnelServer { try { const { frame: routed, path } = this.pathFrame(frame) if (path === '/__boot__') { this.serveBoot(frame, sink); return } - if ((STREAM_PATHS as readonly string[]).includes(path)) { await this.serveDirect(frame, sink); return } if (path.startsWith(`${API_PREFIX}/`)) { await this.serveApi(frame, routed, path, sink); return } this.dispatch(routed, sink) } catch (reason) { diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 40c177a60f..f73f1d2e5c 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -22,6 +22,7 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/worker-host */ import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts' +import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway' import type { AlsCausality } from './polyfill/async-context/als-runtime.ts' import { dirname, join } from './module-system/posix-path.ts' import { installProcessGlobal } from './node/globals/process.ts' @@ -237,6 +238,10 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const apiProxy = ctx.get('apiProxy') if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service') + const typertGateway = ctx.get('typertGateway') as TypertGateway | undefined + if (typertGateway === undefined) { + throw new Error('webworker host: the tree activated without a typertGateway service') + } const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as { toFetchHandler: (api: unknown) => { fetch(request: Request): Promise } } @@ -248,6 +253,8 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { tunnel.serve({ directFetch: (request: Request) => handler.fetch(request), bootPayload: () => readBootPayload(ctx), + openStream: typertGateway.wireStream.open, + streamFailure: typertGateway.wireStream.failure, }) } catch (reason) { tunnel.fail(reason) diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index c5dc4ed215..ceacba25df 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -27,17 +27,31 @@ const warnings: string[] = [] console.warn = (message: string) => { warnings.push(message) } ;(globalThis as { location?: unknown }).location = { origin: 'http://localhost:4173' } -type Listener = (event: { data: unknown }) => void +type StubListener = (event: { data?: unknown; message?: string }) => void /** A worker stand-in: collects what the page sent, replays what the test delivers. */ -function stubWorker(): { worker: Worker; sent: { t: string; id: number }[]; deliver: (frame: unknown) => void } { - const listeners: Listener[] = [] +function stubWorker(): { + worker: Worker + sent: { t: string; id: number }[] + deliver: (frame: unknown) => void + fail: (message: string) => void +} { + const listeners: StubListener[] = [] + const errorListeners: StubListener[] = [] const sent: { t: string; id: number }[] = [] const worker = { - addEventListener: (type: string, listener: Listener) => { if (type === 'message') listeners.push(listener) }, + addEventListener: (type: string, listener: StubListener) => { + if (type === 'message') listeners.push(listener) + if (type === 'error') errorListeners.push(listener) + }, postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number }) }, } as unknown as Worker - return { worker, sent, deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) } } + return { + worker, + sent, + deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) }, + fail: (message) => { for (const listener of errorListeners) listener({ message }) }, + } } // A normal reply resolves and says nothing on the console. @@ -129,3 +143,90 @@ function stubWorker(): { worker: Worker; sent: { t: string; id: number }[]; deli // A late reply to an aborted request must not resurrect it. deliver({ t: 'res', id: 1, status: 200, headers: {}, message: 'late' }) } + +// A logical Gateway stream carries decoded values and one terminal frame. +{ + const { worker, sent, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const signal = new AbortController() + const stream = tunnel.open('session/follow', { args: { sessionId: 'session-1' } }, signal.signal) + [Symbol.asyncIterator]() + const first = stream.next() + check('a logical stream opens on the worker-local carrier', sent[0], { + t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } }, + }) + deliver({ t: 'stream-item', id: 1, value: { type: 'baseline' } }) + check('a logical stream yields decoded values', await first, { value: { type: 'baseline' }, done: false }) + const ended = stream.next() + deliver({ t: 'stream-end', id: 1 }) + check('a logical stream closes normally', await ended, { done: true, value: undefined }) + check('normal stream completion sends no cancellation', sent, [ + { t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } } }, + ]) +} + +// Host failures retain their code and details for the Gateway Client bundle to normalize. +{ + const { worker, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const pending = tunnel.open('session/follow', {}, new AbortController().signal).next() + deliver({ + t: 'stream-error', + id: 1, + failure: { + kind: 'remote', + code: 'session-not-found', + message: 'fixture Session is absent', + details: { sessionId: 'session-1' }, + }, + }) + const failure = await pending.then(() => undefined, (error: unknown) => error as { + message: string + dshRemoteStreamFailure: unknown + }) + check('a logical Host failure retains its structural marker', { + message: failure?.message, + dshRemoteStreamFailure: failure?.dshRemoteStreamFailure, + }, { + message: 'fixture Session is absent', + dshRemoteStreamFailure: { + kind: 'remote', code: 'session-not-found', details: { sessionId: 'session-1' }, + }, + }) +} + +// Caller cancellation keeps the caller's exact reason and reaches the worker once. +{ + const { worker, sent, deliver } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const abort = new AbortController() + const pending = tunnel.open('workspace/follow', {}, abort.signal).next() + const reason = new Error('caller stopped the Workspace feed') + abort.abort(reason) + deliver({ t: 'stream-item', id: 1, value: 'late' }) + check('logical stream cancellation preserves the caller reason', await pending.then( + () => 'resolved', + (error: unknown) => error === reason ? 'same reason' : 'different reason', + ), 'same reason') + check('logical stream cancellation reaches the worker', sent.at(-1), { t: 'abort', id: 1 }) +} + +// A failed worker is a carrier failure, not a fabricated Host Remote error. +{ + warnings.length = 0 + const { worker, fail } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const pending = tunnel.open('$events', { args: {} }, new AbortController().signal).next() + fail('worker crashed') + const failure = await pending.then(() => undefined, (error: unknown) => error as { + message: string + dshRemoteStreamFailure: unknown + }) + check('worker failure carries the carrier marker', { + message: failure?.message, + dshRemoteStreamFailure: failure?.dshRemoteStreamFailure, + }, { + message: 'web-preview tunnel: worker failed: worker crashed', + dshRemoteStreamFailure: { kind: 'carrier' }, + }) +} diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts new file mode 100644 index 0000000000..f9107e1037 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TunnelOutboundFrame } from '../../src/transport/frames.ts' +import { TunnelServer, type TunnelSeams } from '../../src/transport/tunnel.ts' + +function harness(): { server: TunnelServer; frames: TunnelOutboundFrame[] } { + const frames: TunnelOutboundFrame[] = [] + const server = new TunnelServer({ + port: { postMessage: (frame) => { frames.push(frame) } }, + requestListener: () => Promise.reject(new Error('fixture has no HTTP listener')), + }) + return { server, frames } +} + +function seams(openStream: TunnelSeams['openStream']): TunnelSeams { + return { + directFetch: () => Promise.reject(new Error('fixture has no direct fetch')), + bootPayload: () => ({}), + openStream, + streamFailure: error => ({ + code: 'fixture-stream-failed', + message: error instanceof Error ? error.message : String(error), + details: { fixture: true }, + }), + } +} + +describe('worker tunnel logical streams', () => { + it('drains a pre-boot open through the worker-local Gateway seam', async () => { + const { server, frames } = harness() + const seen: unknown[] = [] + server.handleMessage({ + t: 'stream-open', id: 1, endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } }, + }) + expect(frames).toEqual([]) + + server.serve(seams(async (endpoint, payload, signal) => { + seen.push(endpoint, payload, signal) + return (async function *(): AsyncGenerator { + yield { type: 'baseline' } + yield { type: 'event', seq: 1 } + })() + })) + + await vi.waitFor(() => { + expect(frames).toEqual([ + { t: 'stream-item', id: 1, value: { type: 'baseline' } }, + { t: 'stream-item', id: 1, value: { type: 'event', seq: 1 } }, + { t: 'stream-end', id: 1 }, + ]) + }) + expect(seen).toEqual([ + 'session/follow', + { args: { sessionId: 'session-1' } }, + expect.any(AbortSignal), + ]) + }) + + it('cancels one logical stream without emitting a terminal frame', async () => { + const { server, frames } = harness() + const opened = Promise.withResolvers() + const stopped = Promise.withResolvers() + server.serve(seams(async (_endpoint, _payload, signal) => { + opened.resolve(signal) + return (async function *(): AsyncGenerator { + yield 'ready' + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + stopped.resolve(undefined) + })() + })) + server.handleMessage({ t: 'stream-open', id: 2, endpoint: '$events', payload: { args: {} } }) + const signal = await opened.promise + await vi.waitFor(() => { expect(frames).toContainEqual({ t: 'stream-item', id: 2, value: 'ready' }) }) + + server.handleMessage({ t: 'abort', id: 2 }) + await stopped.promise + expect(signal.aborted).toBe(true) + expect(frames).toEqual([{ t: 'stream-item', id: 2, value: 'ready' }]) + }) + + it('maps a Host stream failure through Gateway-owned fields', async () => { + const { server, frames } = harness() + server.serve(seams(async () => { throw new Error('Host stream exploded') })) + server.handleMessage({ t: 'stream-open', id: 3, endpoint: 'probe/watch', payload: {} }) + + await vi.waitFor(() => { + expect(frames).toEqual([{ + t: 'stream-error', + id: 3, + failure: { + kind: 'remote', + code: 'fixture-stream-failed', + message: 'Host stream exploded', + details: { fixture: true }, + }, + }]) + }) + }) + + it('refuses queued and future streams after boot failure as carrier failures', () => { + const { server, frames } = harness() + server.handleMessage({ t: 'stream-open', id: 4, endpoint: '$events', payload: {} }) + server.fail(new Error('image failed')) + server.handleMessage({ t: 'stream-open', id: 5, endpoint: '$events', payload: {} }) + + expect(frames).toEqual([4, 5].map(id => ({ + t: 'stream-error', + id, + failure: { kind: 'carrier', message: 'Error: image failed' }, + }))) + }) +}) diff --git a/packages/experimental/webworker-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index b2419b626a..1c7b93807b 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../api/gateway/tsconfig.host.json" + }, { "path": "../../client/modules" }, diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 3b183d6839..3bc0539e38 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-modules", "@deepseek-ai/dsh-client-ui-theme" @@ -50,7 +50,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -60,7 +60,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", diff --git a/packages/extensions/cordis-client-runner/src/client/guard.ts b/packages/extensions/cordis-client-runner/src/client/guard.ts index 831209f7a4..f3d9bf9d40 100644 --- a/packages/extensions/cordis-client-runner/src/client/guard.ts +++ b/packages/extensions/cordis-client-runner/src/client/guard.ts @@ -15,7 +15,7 @@ import { Context } from '@deepseek-ai/cordis' import type { DynamicCordisPackage } from '@deepseek-ai/dsh-api-remotes/client' -import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' /** Facade verbs beyond declared services (host CTX_VERBS twin). */ diff --git a/packages/extensions/cordis-client-runner/src/client/index.ts b/packages/extensions/cordis-client-runner/src/client/index.ts index f54bfc2318..da8b9343ed 100644 --- a/packages/extensions/cordis-client-runner/src/client/index.ts +++ b/packages/extensions/cordis-client-runner/src/client/index.ts @@ -16,7 +16,7 @@ import type { DynamicCordisInventoryRow, } from '@deepseek-ai/dsh-api-remotes/client' import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' -import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' // The Client Remote assembly is the one place the two planes meet: it mounts the // `dynamicCordisRunner` namespace and re-exports its payload vocabulary, so this // package names what it sends without importing a Host package. diff --git a/packages/extensions/cordis-client-runner/src/client/providers.ts b/packages/extensions/cordis-client-runner/src/client/providers.ts index d140ec454a..ad38aaa91d 100644 --- a/packages/extensions/cordis-client-runner/src/client/providers.ts +++ b/packages/extensions/cordis-client-runner/src/client/providers.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client' -import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import { queryEventApi, queryServiceApi } from './api-catalog.ts' import type { ClientCordisInspectProviderRegistration } from './inspect-registry.ts' diff --git a/packages/extensions/cordis-client-runner/src/client/runtime.ts b/packages/extensions/cordis-client-runner/src/client/runtime.ts index 8f5b356de7..f89d2220af 100644 --- a/packages/extensions/cordis-client-runner/src/client/runtime.ts +++ b/packages/extensions/cordis-client-runner/src/client/runtime.ts @@ -21,7 +21,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' -import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { DynamicCordisStyles, evaluateClientHalf, DYNAMIC_CLIENT_REDIRECTS } from './evaluator.ts' import type { DynamicCordisEvaluatedPlugin } from './evaluator.ts' import { dynamicCordisContext } from './guard.ts' diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 08b22ffdef..3419d73e89 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -91,13 +91,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: MaybeSnapshotSelectorHook', - 'sessionId: SessionId | undefined', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', + 'useSession: MaybeSnapshotSelectorHook', + 'sessionId: SessionId | undefined', + 'useProjection: UseProjection', ], keyDomain: '', hookContext: '', @@ -108,14 +111,50 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation\', () => ctx.slots.register(\n { name: \'conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:62', + source: 'packages/client/ui-layout/src/client/index.ts:64', + }, + { + key: 'conversation.approval.detail', + kind: 'single', + scope: 'session', + summary: 'Optional detail for the Tool call correlated with an approval request.', + doc: 'Optional detail for the Tool call correlated with an approval request.', + registerOptions: [], + ownerProps: [ + '/** Stable identity handed to an optional approval-detail renderer. */\nexport interface ApprovalDetailOwnerProps {\n /** Tool call correlated with the request. */\n callId: CallId\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.composer\' (client-ui-approval), so it exists while that entry is mounted', + occupants: [ + 'client-ui-chat ApprovalCommand', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.approval.detail\', () => ctx.slots.register(\n { name: \'conversation.approval.detail\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-approval/src/client/contract/slots.ts:25', }, { key: 'conversation.chat.assistant-actions', kind: 'list', scope: 'session', - summary: 'Action strip attached to one finalized assistant message, rendered inside that message\'s IconActions row.', - doc: 'Action strip attached to one finalized assistant message, rendered\ninside that message\'s IconActions row. The chat entry owns the render\nsite and passes the addressed message identity; contributors add\nper-message actions without importing the conversation implementation.\nEntries render by ascending `order`.', + summary: 'Ordered actions for one finalized assistant message.', + doc: 'Ordered actions for one finalized assistant message. Each entry receives\nthe durable message id; a fresh `id` adds an action and reusing one replaces\nthat entry. With no entries, the standard action row remains unchanged.', registerOptions: [ { name: 'id', @@ -137,37 +176,42 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * Owner currency of the assistant-message action strip: the durable identity\n * of the one finalized message the contributed actions address. Only finalized\n * messages reach this slot, so the id is always present.\n */\nexport interface AssistantActionOwnerProps {\n /** Stable identity carried from the `assistant/message` event. */\n messageId: MessageId\n}', + '/** Owner currency of finalized-assistant actions. */\nexport interface AssistantActionOwnerProps {\n messageId: MessageId\n}', ], ownerPropsReferences: [ 'MessageId', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-message-feedback MessageFeedbackActions id \'feedback\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:148', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', }, { key: 'conversation.chat.commandview', kind: 'keyed', scope: 'session', - summary: 'The chat view\'s per-command row hole: keyed dispatch on the command name (`command/run.name`; a run-less cross-window node has none and always lands on the fallback).', - doc: 'The chat view\'s per-command row hole: keyed dispatch on the command\nname (`command/run.name`; a run-less cross-window node has none and\nalways lands on the fallback). Declared by the chat view entry; the\nrender site dispatches via `entryKey: name` with GenericCommandCard as\nthe `fallback` — a slash command renders durably with zero\nregistration, and a domain upgrades by registering one row component.', + summary: 'Command row keyed by the command name.', + doc: 'Command row keyed by the command name. The component receives the folded\ncommand lifecycle and linked compaction when present. Reusing a key\nreplaces that command renderer; an unoccupied key uses the generic card.', registerOptions: [ { name: 'key', @@ -177,36 +221,42 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * Owner share of the per-command row slot: the frozen {@link CommandNode}\n * slice off the snapshot (cache-stable reference — memo premise). The node\n * carries the whole lifecycle (structured name/args, pairing id, and\n * outcome-or-executing). A successful domain command may also carry the\n * explicitly linked projection node needed to fold two log records into one\n * presentation row.\n */\nexport interface CommandRowOwnerProps {\n /** Folded command lifecycle node (run + optional done). */\n node: CommandNode\n /** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */\n compaction?: CompactionSummaryNode\n}', + '/** Command-row owner share. */\nexport interface CommandRowOwnerProps {\n node: CommandNode\n compaction?: CompactionSummaryNode\n}', ], ownerPropsReferences: [ + 'Command', 'CommandNode', 'CompactionSummaryNode', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted', occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:185', }, { key: 'conversation.chat.node', kind: 'keyed', scope: 'session', - summary: 'Final business node renderer, dispatched by `ChatConversationViewNode.kind`.', - doc: 'Final business node renderer, dispatched by `ChatConversationViewNode.kind`.', + summary: 'Final Chat node renderer, keyed by `ChatNodeKind`.', + doc: 'Final Chat node renderer, keyed by `ChatNodeKind`. The component receives\nthe typed node, shared Chat actions, and Turn-data hook. Reusing a key\nreplaces that node renderer; a kind with no occupant renders no row.', registerOptions: [ { name: 'key', @@ -216,7 +266,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to one keyed Chat business renderer. */\nexport interface ChatNodeOwnerProps {\n /** Selected Tool call, when the shared details store names one. */\n selectedCallId?: CallId | undefined\n /** Session workspace root; Tool summaries display paths relative to it. */\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: CallId) => void\n forkAt: (seq: number) => void\n /** Render a historical image group through the attachment slot. */\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n selectedCallId?: CallId | undefined\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: CallId) => void\n forkAt: (seq: number) => void\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', @@ -224,45 +274,50 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'TurnTailOwnerProps', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, user, workflow-run', hookContext: 'string', slotInject: 'ChatNodeTurnDataInjected', - declaredBy: 'an entry in \'conversation.view\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation UserMessageNodeView key \'user\'', - 'client-ui-conversation UserMessageNodeView key \'steering\'', - 'client-ui-conversation ContextMessageNodeView key \'context\'', - 'client-ui-conversation AssistantNodeView key \'assistant-step\'', - 'client-ui-conversation CommandNodeView key \'command\'', - 'client-ui-conversation ManualCompactionNodeView key \'manual-compaction\'', - 'client-ui-conversation CompactionNodeView key \'compaction\'', - 'client-ui-conversation RetryNodeView key \'model-retry\'', - 'client-ui-conversation TurnErrorNodeView key \'turn-error\'', - 'client-ui-conversation TurnMaxTokensNodeView key \'turn-max-tokens\'', - 'client-ui-conversation TurnTailNodeView key \'turn-tail\'', - 'client-ui-conversation UnknownNodeView key \'unknown\'', + 'client-ui-chat UserMessageNodeView key \'user\'', + 'client-ui-chat UserMessageNodeView key \'steering\'', + 'client-ui-chat ContextMessageNodeView key \'context\'', + 'client-ui-chat AssistantNodeView key \'assistant-step\'', + 'client-ui-chat CommandNodeView key \'command\'', + 'client-ui-chat ManualCompactionNodeView key \'manual-compaction\'', + 'client-ui-chat CompactionNodeView key \'compaction\'', + 'client-ui-chat RetryNodeView key \'model-retry\'', + 'client-ui-chat TurnErrorNodeView key \'turn-error\'', + 'client-ui-chat TurnMaxTokensNodeView key \'turn-max-tokens\'', + 'client-ui-chat TurnTailNodeView key \'turn-tail\'', + 'client-ui-chat UnknownNodeView key \'unknown\'', 'client-ui-goal GoalCommandInputView key \'command-input\'', 'client-ui-tool ToolCallTree key \'tool-call\'', 'client-ui-workflow-run WorkflowRunPanel key \'workflow-run\'', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:115', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:166', }, { key: 'conversation.chat.turnTail', kind: 'chain', scope: 'session', - summary: 'The completed Turn Node\'s extension chain, rendered before that Node\'s IconActions.', - doc: 'The completed Turn Node\'s extension chain, rendered before that Node\'s\nIconActions. Entries derive a match from the engine-owned Turn and\nclosing seq before mounting, so presentation components never mount\nonly to return null; an all-declined chain renders nothing.', + summary: 'Selector-routed extension before a completed Turn\'s action row.', + doc: 'Selector-routed extension before a completed Turn\'s action row. The\ncomponent receives the Turn, closing sequence, and file opener. The first\nselector that accepts the owner renders; an all-declined chain is empty.', registerOptions: [ { name: 'select', @@ -272,37 +327,42 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * Owner currency of the chat view\'s turn-tail hole: the engine-owned Turn and\n * the closing assistant\'s anchor. Registrants read their own typed Turn data\n * and open files through the same opener the tool rows use.\n */\nexport interface TurnTailOwnerProps {\n /** Engine-owned closing Turn boundary. */\n turn: TurnLocation\n /** The closing assistant\'s seq — the anchor the tail renders under. */\n seq: number\n /**\n * Open a filesystem path through the Host (tool-row semantics; the chat\n * view resolves relative paths against the session cwd).\n */\n openFile: (path: string) => void\n}', + '/** Owner currency of the completed-Turn extension chain. */\nexport interface TurnTailOwnerProps {\n turn: TurnLocation\n seq: number\n openFile: (path: string) => void\n}', ], ownerPropsReferences: [ 'TurnLocation', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-deliverables ProducedFiles', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:140', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:191', }, { key: 'conversation.composer', kind: 'chain', scope: 'session', - summary: 'The composer takeover chain: entries are selector-routed replacements of the default InputBar.', - doc: 'The composer takeover chain: entries are selector-routed replacements\nof the default InputBar. Declared by this package\'s \'conversation\'\nentry; the owner dispatches the ComposerChainProps currency and\nrouting lives in entry selectors — new takeover kinds register with\nzero owner changes.', + summary: 'Selector-routed replacements for the current Session\'s resident composer.', + doc: 'Selector-routed replacements for the current Session\'s resident composer.', registerOptions: [ { name: 'select', @@ -312,55 +372,64 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * Composer chain currency: what ConversationRoot dispatches at its\n * renderSlotChain site. The owner declares the currency only — never a\n * per-entry contract; takeover packages narrow it in their own selectors\n * (`interactions.find(i => i.kind === ...)`), so new takeover kinds register\n * with zero owner changes.\n */\nexport interface ComposerChainProps {\n interactions: readonly PendingInteraction[]\n /** Current conversation facts for feature-owned takeover selectors. */\n session: ConversationSnapshot | undefined\n}', + '/** Owner values used to elect a composer takeover. */\nexport interface ComposerChainProps {\n /** Current Session identity used by temporary business-owned entries. */\n sessionId: SessionId | undefined\n /** Current Session lifecycle state, absent without a selected Session. */\n session: SessionSnapshot | undefined\n /** Effective business-owned interaction awaiting the user in this Session. */\n pendingInteraction: SessionPendingInteraction | undefined\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', - 'PendingInteraction', + 'SessionId', + 'SessionPendingInteraction', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation ApprovalPanel', + 'client-ui-approval ApprovalPanel', 'client-ui-subagent SubagentReadOnlyComposer', 'client-ui-user-questions QuestionComposer', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78', }, { key: 'conversation.composer.bar', kind: 'single', scope: 'session-maybe', - summary: 'The default composer body: a single slot rendered as the composer chain\'s fallback (a real entry, not a chain rider, so a takeover election hides rather than unmounts it and the textarea DOM survives).', - doc: 'The default composer body: a single slot rendered as the composer\nchain\'s fallback (a real entry, not a chain rider, so a\ntakeover election hides rather than unmounts it and the textarea DOM\nsurvives). Session-maybe: the bar stays mounted across the\nno-session/session transition — the no-workspace hero renders the SAME\ntextarea DOM as a read-only Workspace-picker trigger instead of a\nparallel inert tree — with the machine hooks absent until a session is\ncurrent. InputBar registers\nhere from this package\'s apply; its machine state arrives through the\nstandard provide channel (useInput + inputActions), the keyboard\ncommand face through its own inject.', + summary: 'Resident composer body, including the no-Session inert state.', + doc: 'Resident composer body, including the no-Session inert state.', registerOptions: [], ownerProps: [ - '/**\n * Owner share of the composer-bar slot: ConversationRoot\'s layout-phase\n * inputs plus the input-region child-slot content it renders (the region\n * slots stay declared/rendered by the conversation entry; the bar hosts the\n * results as chrome).\n */\nexport interface ComposerBarOwnerProps {\n /** Hero = empty-state centered card; composer = resident bottom bar. */\n variant: \'hero\' | \'composer\'\n /**\n * A block another plugin raised for this session: the bar refuses input and\n * shows the blocker\'s reason as the placeholder, but — unlike `disabled` —\n * keeps the model seat live. Every block this contract has is one the user\n * clears by choosing a model, so locking that seat too would leave the\n * composer telling them to do the one thing it prevents.\n */\n blocked?: { readonly reason: string }\n /**\n * Inert no-workspace state: the bar locks message actions while preserving\n * its normal DOM so the Workspace pick transitions in place.\n */\n disabled?: boolean\n /** Whether the shared Workspace picker menu is expanded, regardless of which trigger opened it. */\n workspacePickerOpen?: boolean\n /** Open the existing Workspace picker from the inert textarea. */ /* …truncated — full shape in source */', + '/** Owner share of the resident composer bar. */\nexport interface ComposerBarOwnerProps {\n /** Hero uses centered placement; composer uses the active bottom placement. */\n variant: \'hero\' | \'composer\'\n /** A feature-owned reason that makes message input inert while leaving model selection live. */\n blocked?: { readonly reason: string }\n /** Lock all message actions while preserving the resident textarea. */\n disabled?: boolean\n /** Whether the shared Workspace picker is expanded. */\n workspacePickerOpen?: boolean\n /** Open the Workspace picker from the inert textarea. */\n onRequestWorkspace?: () => void\n placeholder?: string\n /** Optional content rendered above the textarea. */\n accessory?: ReactNode\n /** Floating overlay content rendered inside the composer card. */\n overlay?: ReactNode\n /** Left-side input controls. */\n leftItems?: ReactNode\n /** Right-side input controls. */\n rightItems?: ReactNode\n /** Ambient content below the card. */\n footer?: ReactNode\n}', ], ownerPropsReferences: [ 'Workspace', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: MaybeSnapshotSelectorHook', - 'sessionId: SessionId | undefined', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', + 'useSession: MaybeSnapshotSelectorHook', + 'sessionId: SessionId | undefined', + 'useProjection: UseProjection', ], keyDomain: '', hookContext: '', @@ -371,14 +440,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:245', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96', }, { key: 'conversation.composer.dock', kind: 'list', scope: 'session', - summary: 'The band under the composer card, inside the bar\'s width column — the seat for an ambient readout about the conversation (the shipped stats line lives here).', - doc: 'The band under the composer card, inside the bar\'s width column — the\nseat for an ambient readout about the conversation (the shipped stats\nline lives here). Same InputZone owner share as the other\nregions. Anything the user must click belongs in the tool row instead\n(`conversation.input.left` / `.right`); anything needing its own line\nabove the card belongs in `conversation.input.dock`.', + summary: 'Ambient entries below the composer card.', + doc: 'Ambient entries below the composer card.', registerOptions: [ { name: 'id', @@ -400,77 +469,89 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * The input-region slot currency: dock/left/right entries read\n * the conversation snapshot and the live input state as owner props (both\n * are point-in-time snapshots — the dispatching skeleton re-renders on\n * either store\'s change, so entries stay current without subscribing).\n */\nexport interface InputZone {\n readonly session: ConversationSnapshot\n readonly input: InputState\n}', + '/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', 'InputState', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation StatsLine id \'stats\'', + 'client-ui-chat StatsLine id \'stats\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90', }, { key: 'conversation.details.tool', kind: 'single', scope: 'session', - summary: 'The body of the details panel for the tool call the user selected — one occupant, so taking it means rendering every tool\'s output, not just the ones you know.', - doc: 'The body of the details panel for the tool call the user selected —\none occupant, so taking it means rendering every tool\'s output, not just\nthe ones you know. The owner passes a frozen `block` whose two lifecycle\nforms must both be handled: branch on `\'kind\' in block` (a settled\n`ToolResultNode` has it, a still-running call does not), and treat\n`cwd` as display-only, for shortening workspace-rooted paths.\nA per-tool renderer belongs in the keyed `tool.call.toolview` seat\ninstead; this one is the whole panel.', + summary: 'Whole details-panel body for the selected Tool call.', + doc: 'Whole details-panel body for the selected Tool call. The component receives\nthe running or settled block and optional workspace root. A registration\nreplaces the shipped Tool details renderer; absence uses the raw fallback.', registerOptions: [], ownerProps: [ - '/** Owner currency of the details panel\'s Tool output renderer. */\nexport interface DetailsToolOwnerProps {\n /** Frozen selected call slice. */\n block: ToolCallBlock\n /** Session workspace root for card cwd and relative-path display. */\n cwd?: string | undefined\n}', + '/** Tool block rendered in the details panel. */\nexport interface DetailsToolOwnerProps {\n block: ToolCallBlock\n cwd?: string | undefined\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'details\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'details\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-tool ToolDetails', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:203', }, { key: 'conversation.hero.agentPreset', kind: 'single', scope: 'root', - summary: 'The agent-preset chip beside the workspace picker on the new-session screen.', - doc: 'The agent-preset chip beside the workspace picker on the new-session\nscreen. Root scope: no session exists yet, so the choice is staged for\nthe next one rather than applied to a current one.', + summary: 'Agent-preset control staged for a New Session.', + doc: 'Agent-preset control staged for a New Session.', registerOptions: [], ownerProps: [ - '/** Owner share of the hero agent-preset chip: the shell supplies nothing. */\nexport interface HeroAgentPresetOwnerProps {\n /** Marker field: the chip owns its own roster, staging, and menu state. */\n children?: never\n}', + '/** Owner share of the Hero agent-preset control. */\nexport interface HeroAgentPresetOwnerProps {\n /** Marker field: the occupant owns its roster and staged selection. */\n children?: never\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -481,22 +562,24 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84', }, { key: 'conversation.hero.brand.mark', kind: 'single', scope: 'root', - summary: 'Brand mark leading the blank-session headline.', - doc: 'Brand mark leading the blank-session headline. Declared by this\npackage\'s `conversation` entry; the shell supplies a fish fallback.', + summary: 'Brand mark shown before the blank-session headline.', + doc: 'Brand mark shown before the blank-session headline.', registerOptions: [], ownerProps: [ - '/** Presentation props supplied to the blank-session brand-mark occupant. */\nexport interface HeroBrandMarkOwnerProps {\n /** Requested square edge in pixels. */\n size: number\n /** Host CSS class for preserving the default hero mark color and hover motion. */\n className?: string | undefined\n}', + '/** Presentation props supplied to the blank-session brand mark. */\nexport interface HeroBrandMarkOwnerProps {\n /** Requested square edge in pixels. */\n size: number\n /** Host class preserving the surrounding mark geometry. */\n className?: string | undefined\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -507,24 +590,26 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:82', }, { key: 'conversation.hero.workspace', kind: 'single', scope: 'root', - summary: 'The hero-phase Workspace picker hole: rendered by ConversationRoot while the session is blank (picking another workspace switches to that workspace\'s blank session, draft carried).', - doc: 'The hero-phase Workspace picker hole: rendered by ConversationRoot\nwhile the session is blank (picking another workspace switches to that\nworkspace\'s blank session, draft carried). Root scope: the picker\nreads the global workspace list.', + summary: 'Workspace picker shown by the blank-session Hero.', + doc: 'Workspace picker shown by the blank-session Hero.', registerOptions: [], ownerProps: [ - '/** Owner share common to the hero / New-Session Workspace pickers. */\nexport interface EmptyWorkspaceOwnerProps {\n open: boolean\n anchorRef?: RefObject\n /** Currently active workspace (renders a trailing check in the picker list). */\n selectedId?: WorkspaceId | undefined\n onPick: (workspaceId: WorkspaceId) => void\n onClose: () => void\n}', + '/** Owner share common to blank-session Workspace pickers. */\nexport interface EmptyWorkspaceOwnerProps {\n open: boolean\n anchorRef?: RefObject\n /** Currently selected Workspace, when available. */\n selectedId?: WorkspaceId | undefined\n onPick: (workspaceId: WorkspaceId) => void\n onClose: () => void\n}', ], ownerPropsReferences: [ 'Workspace', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -535,7 +620,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:80', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -549,8 +634,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -568,24 +655,27 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'conversation.input.attachments', kind: 'single', scope: 'session-maybe', - summary: 'Optional draft-image rail, drop target, and preview surface inside the composer.', - doc: 'Optional draft-image rail, drop target, and preview surface inside the composer.', + summary: 'Optional draft-image rail and drop target.', + doc: 'Optional draft-image rail and drop target.', registerOptions: [], ownerProps: [ - '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft images in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add images now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddImages: (files: readonly File[]) => void\n /** Remove one draft image through the conversation service. */\n onRemoveImage: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', + '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft images in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add images now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddImages: (files: readonly File[]) => void\n /** Remove one draft image through the Conversation service. */\n onRemoveImage: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', ], ownerPropsReferences: [ 'ComposerAttachment', 'DraftAttachmentId', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: MaybeSnapshotSelectorHook', - 'sessionId: SessionId | undefined', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', + 'useSession: MaybeSnapshotSelectorHook', + 'sessionId: SessionId | undefined', + 'useProjection: UseProjection', ], keyDomain: '', hookContext: '', @@ -596,14 +686,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:247', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98', }, { key: 'conversation.input.dock', kind: 'list', scope: 'session', - summary: 'A full-width row of its own, stacked above the composer card — the seat for anything that needs a line to itself (queue rows, a todo strip, a goal bar).', - doc: 'A full-width row of its own, stacked above the composer card — the seat\nfor anything that needs a line to itself (queue rows, a todo strip, a\ngoal bar). Pick this over the three seats below when your content wraps\nor carries prose; pick `conversation.composer.dock` for an ambient\nreadout under the card, and `conversation.input.left` /\n`.right` for a small control INSIDE the card\'s tool row.\nRead only `session`/`input` off the owner share (InputZone) —\nboth are point-in-time snapshots re-rendered for you, never subscribe.', + summary: 'Full-width entries above the composer card.', + doc: 'Full-width entries above the composer card.', registerOptions: [ { name: 'id', @@ -625,20 +715,25 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * The input-region slot currency: dock/left/right entries read\n * the conversation snapshot and the live input state as owner props (both\n * are point-in-time snapshots — the dispatching skeleton re-renders on\n * either store\'s change, so entries stay current without subscribing).\n */\nexport interface InputZone {\n readonly session: ConversationSnapshot\n readonly input: InputState\n}', + '/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', 'InputState', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -651,14 +746,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:205', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:86', }, { key: 'conversation.input.left', kind: 'list', scope: 'session', - summary: 'The left end of the tool row INSIDE the composer card, after the resident chrome (access mode, plan, attach) — the seat for a small always-visible control.', - doc: 'The left end of the tool row INSIDE the composer card, after the\nresident chrome (access mode, plan, attach) — the seat for a small\nalways-visible control. Entries sit beside that chrome, never replace\nit. Same InputZone owner share; use `.right` for a control that\nbelongs next to the send button, and the docks for anything taller than\none row.', + summary: 'Compact controls at the left of the composer tool row.', + doc: 'Compact controls at the left of the composer tool row.', registerOptions: [ { name: 'id', @@ -680,20 +775,25 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * The input-region slot currency: dock/left/right entries read\n * the conversation snapshot and the live input state as owner props (both\n * are point-in-time snapshots — the dispatching skeleton re-renders on\n * either store\'s change, so entries stay current without subscribing).\n */\nexport interface InputZone {\n readonly session: ConversationSnapshot\n readonly input: InputState\n}', + '/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', 'InputState', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -702,27 +802,32 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:223', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92', }, { key: 'conversation.input.model', kind: 'single', scope: 'session', - summary: 'The named model-select seat at the right end of the composer tool row, left of the send button — one occupant, so taking it means rendering the whole model affordance yourself.', - doc: 'The named model-select seat at the right end of the composer tool row,\nleft of the send button — one occupant, so taking it means rendering the\nwhole model affordance yourself. Same `locked`-only owner share and same\nrenders-nothing-while-empty contract as the plan seat. Note the composer\ndeliberately keeps this seat LIVE while it refuses text for a\nmodel-related block: every such block is one the user clears by picking\na model here.', + summary: 'Model selector inside the composer tool row.', + doc: 'Model selector inside the composer tool row.', registerOptions: [], ownerProps: [ - '/**\n * Owner share of the two named composer control seats (plan / model): the\n * bar passes its disable state; the filling entry owns everything else.\n */\nexport interface InputControlOwnerProps {\n /** Session-removed lock (the bar\'s chrome disable state). */\n locked: boolean\n}', + '/** Owner share of the named plan and model controls. */\nexport interface InputControlOwnerProps {\n /** Whether the composer currently refuses interaction. */\n locked: boolean\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -733,14 +838,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:271', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106', }, { key: 'conversation.input.overlay', kind: 'list', scope: 'session', - summary: 'The InputBar floating overlay anchor: MenuView (this package) and the popupSelect shell (ui-commands) contribute list entries; each reads its own store and renders null while closed.', - doc: 'The InputBar floating overlay anchor: MenuView (this package) and the\npopupSelect shell (ui-commands) contribute list entries; each reads its\nown store and renders null while closed. Declared (children table) by\nui-conversation\'s composer entry; the anchor hides with the input\nunder a takeover.', + summary: 'Floating entries rendered inside the resident composer card.', + doc: 'Floating entries rendered inside the resident composer card.', registerOptions: [ { name: 'id', @@ -764,13 +869,18 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -782,27 +892,32 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-input-trigger/src/client/slots.ts:24', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:88', }, { key: 'conversation.input.plan', kind: 'single', scope: 'session', - summary: 'The named plan-status seat in the composer tool row, immediately right of the access-mode control — one occupant, so taking it means rendering the plan affordance yourself.', - doc: 'The named plan-status seat in the composer tool row, immediately right\nof the access-mode control — one occupant, so taking it means rendering\nthe plan affordance yourself. The owner passes only `locked` (see\nInputControlOwnerProps): honour it by refusing interaction, and\ntake everything else from the framework session kit or your own inject.\nUnoccupied, the seat renders nothing at all — the bar paints no\nplaceholder, so an absent plan plugin costs no layout.', + summary: 'Plan control inside the composer tool row.', + doc: 'Plan control inside the composer tool row.', registerOptions: [], ownerProps: [ - '/**\n * Owner share of the two named composer control seats (plan / model): the\n * bar passes its disable state; the filling entry owns everything else.\n */\nexport interface InputControlOwnerProps {\n /** Session-removed lock (the bar\'s chrome disable state). */\n locked: boolean\n}', + '/** Owner share of the named plan and model controls. */\nexport interface InputControlOwnerProps {\n /** Whether the composer currently refuses interaction. */\n locked: boolean\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -813,14 +928,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:261', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104', }, { key: 'conversation.input.right', kind: 'list', scope: 'session', - summary: 'The right end of the same tool row, before the primary send button — the seat for a control the user reaches on the way to sending (the model select sits in its own named seat just left of here).', - doc: 'The right end of the same tool row, before the primary send button —\nthe seat for a control the user reaches on the way to sending (the\nmodel select sits in its own named seat just left of here). Same\nInputZone owner share and the same one-row height budget as\n`conversation.input.left`.', + summary: 'Compact controls before the composer submit action.', + doc: 'Compact controls before the composer submit action.', registerOptions: [ { name: 'id', @@ -842,20 +957,25 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * The input-region slot currency: dock/left/right entries read\n * the conversation snapshot and the live input state as owner props (both\n * are point-in-time snapshots — the dispatching skeleton re-renders on\n * either store\'s change, so entries stay current without subscribing).\n */\nexport interface InputZone {\n readonly session: ConversationSnapshot\n readonly input: InputState\n}', + '/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}', ], ownerPropsReferences: [ - 'ConversationSnapshot', 'InputState', + 'SessionSnapshot', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -864,59 +984,68 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94', }, { key: 'conversation.message.images', kind: 'single', scope: 'session', - summary: 'Optional renderer for one consecutive group of durable message images.', - doc: 'Optional renderer for one consecutive group of durable message images.', + summary: 'Renderer for one consecutive group of durable message images.', + doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Historical image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Consecutive image blocks rendered as one gallery. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized durable image loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n /** Message-side alignment. */\n align: \'start\' | \'end\'\n}', + '/** Historical image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n loadImage: (attachment: ImageAttachmentRef) => Promise\n align: \'start\' | \'end\'\n}', ], ownerPropsReferences: [ 'ImageAttachmentRef', - 'Message', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation.view\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-attachment MessageImages', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:124', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:179', }, { key: 'conversation.session', kind: 'single', scope: 'session', - summary: 'The entire body of one session: taking this seat means rendering that session\'s conversation yourself.', - doc: 'The entire body of one session: taking this seat means rendering that\nsession\'s conversation yourself. The occupant also owns the per-session\ndraft mirror and the active view ring, so a replacement inherits both\nduties and an empty one leaves a blank session pane — nothing here\ndegrades gracefully. To ADD rather than replace, take a seat inside the\nflow instead: `conversation.view` for a whole tab, the input regions for\ncomposer chrome.', + summary: 'Strict per-Session Conversation body.', + doc: 'Strict per-Session Conversation body.', registerOptions: [], ownerProps: [], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -927,25 +1056,30 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:71', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:54', }, { key: 'conversation.session.header', kind: 'single', scope: 'session', - summary: 'The strip above the session\'s scrollport: title, view tabs, and the action row.', - doc: 'The strip above the session\'s scrollport: title, view tabs, and the\naction row. Taking this seat means rendering all three yourself, and it\nalso collapses `conversation.session.header.actions` — that additive\nseat is declared by whoever occupies this one, so replacing the header\ntakes every action entry down with it.', + summary: 'Strict per-Session title, actions, and View navigation.', + doc: 'Strict per-Session title, actions, and View navigation.', registerOptions: [], ownerProps: [], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -956,14 +1090,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:79', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:56', }, { key: 'conversation.session.header.actions', kind: 'list', scope: 'session', - summary: 'One button in the session header\'s action row — the additive way to put a per-session control beside the title without replacing the header.', - doc: 'One button in the session header\'s action row — the additive way to put\na per-session control beside the title without replacing the header.\nEntries render by ascending `order`; negative values are reserved for\nstatic session context that precedes interactive actions. The owner\npasses nothing: everything a control needs comes from the framework\nsession kit (`sessionId`, `useSession`, `useInput`, `inputActions`) and\nfrom the registrant\'s own inject face, so an empty owner share means\nself-sufficient, not starved.', + summary: 'Title-adjacent Session actions in ascending order.', + doc: 'Title-adjacent Session actions in ascending order.', registerOptions: [ { name: 'id', @@ -985,17 +1119,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Header actions derive their state from the standard session/global kit. */\nexport interface ConversationHeaderActionOwnerProps {}', + '/** Header actions derive their state from standard Session props. */\nexport interface ConversationHeaderActionOwnerProps {\n /** Marker field: entries receive no owner-specific values. */\n children?: never\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -1007,29 +1146,34 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:100', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:64', }, { key: 'conversation.session.header.lineage', kind: 'single', scope: 'session', - summary: 'One breadcrumb title and its lineage controls.', - doc: 'One breadcrumb title and its lineage controls. The render site keeps\nthe ordinary title as fallback; an occupant receives plain title data\nand may replace a subagent title with one combined navigation control.', + summary: 'Optional replacement for one Session breadcrumb title.', + doc: 'Optional replacement for one Session breadcrumb title.', registerOptions: [], ownerProps: [ - '/** Plain breadcrumb data handed to the optional lineage renderer. */\nexport interface ConversationHeaderLineageOwnerProps {\n /** Session represented by this breadcrumb title. */\n lineageSessionId: SessionId\n /** Display title available to a renderer that combines the title with a control. */\n displayTitle: string\n /** Navigate to an ancestor title when its combined control is clicked. */\n openTitle?: () => void\n}', + '/** Plain breadcrumb data handed to the optional lineage renderer. */\nexport interface ConversationHeaderLineageOwnerProps {\n /** Session represented by this breadcrumb title. */\n lineageSessionId: SessionId\n /** Display title available to a combined title/control renderer. */\n displayTitle: string\n /** Navigate to an ancestor title when present. */\n openTitle?: () => void\n}', ], ownerPropsReferences: [ 'SessionId', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -1040,14 +1184,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:85', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:58', }, { key: 'conversation.session.header.utilities', kind: 'list', scope: 'session', - summary: 'Right-aligned Session utilities kept outside the title-adjacent action group, so an optional utility cannot reorder session context or lineage.', - doc: 'Right-aligned Session utilities kept outside the title-adjacent action\ngroup, so an optional utility cannot reorder session context or lineage.', + summary: 'Right-aligned Session utilities in ascending order.', + doc: 'Right-aligned Session utilities in ascending order.', registerOptions: [ { name: 'id', @@ -1069,17 +1213,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Header actions derive their state from the standard session/global kit. */\nexport interface ConversationHeaderActionOwnerProps {}', + '/** Header actions derive their state from standard Session props. */\nexport interface ConversationHeaderActionOwnerProps {\n /** Marker field: entries receive no owner-specific values. */\n children?: never\n}', ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', @@ -1090,14 +1239,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70', }, { key: 'conversation.view', kind: 'list', scope: 'session', - summary: 'The conversation view ring: one list entry per view tab (chat here; trajectory/waterfall from ui-trajectory), rendered one-at-a-time by the session body via `only: `.', - doc: 'The conversation view ring: one list entry per view tab (chat here;\ntrajectory/waterfall from ui-trajectory), rendered one-at-a-time by\nthe session body via `only: `. Declared by this package\'s\nbody entry (declaring is claiming). Session scope: views read the\nconversation snapshot through the standard kit.', + summary: 'Registered Conversation target Views, rendered one at a time.', + doc: 'Registered Conversation target Views, rendered one at a time.', registerOptions: [ { name: 'id', @@ -1119,29 +1268,36 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * View-slot owner share: the cross-view inspect handoff (otherwise views need\n * nothing from the render site — sessionId and the snapshot hook arrive as\n * framework-standard props; tool rows go through each view\'s own declared\n * toolview hole).\n */\nexport interface ConvViewOwnerProps {\n /** One-shot inspect request from another view (chat\'s Inspect button); null when idle. */\n inspect?: { callId: CallId } | null\n /** Acknowledge the inspect request once applied (clears the store field). */\n onInspectDone?: () => void\n}', + '/** Conversation View entries obtain their data from registered standard hooks. */\nexport interface ConvViewOwnerProps {\n /** Focus request addressed to the selected View. */\n viewRequest: import(\'./views.ts\').ConversationViewRequest | null\n /** Select a View and address one opaque focus identity to it. */\n openView: (view: string, focus: string) => void\n /** Acknowledge the current one-shot focus request. */\n completeViewRequest: () => void\n}', + ], + ownerPropsReferences: [ + 'ConversationViewRequest', ], - ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', declaredBy: 'an entry in \'conversation.session\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation ChatView id \'chat\'', + 'client-ui-chat ChatView id \'chat\'', 'client-ui-trajectory TrajectoryView id \'trajectory\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:113', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:76', }, { key: 'details', @@ -1155,24 +1311,29 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: '', hookContext: '', slotInject: '', declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', occupants: [ - 'client-ui-conversation DetailsPanel', + 'client-ui-chat DetailsPanel', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'details\', () => ctx.slots.register(\n { name: \'details\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:72', + source: 'packages/client/ui-layout/src/client/index.ts:74', }, { key: 'root', @@ -1186,8 +1347,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1198,7 +1361,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'root\', () => ctx.slots.register(\n { name: \'root\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/runtime/src/client/slots.ts:41', + source: 'packages/client/ui-renderer/src/client/registry.ts:43', }, { key: 'settings.action', @@ -1231,8 +1394,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1243,7 +1408,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.action\', () => ctx.slots.register(\n { name: \'settings.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:35', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:36', }, { key: 'settings.close', @@ -1257,8 +1422,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1269,7 +1436,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.close\', () => ctx.slots.register(\n { name: \'settings.close\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:41', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:42', }, { key: 'settings.general.item', @@ -1302,8 +1469,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1318,7 +1487,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.general.item\', () => ctx.slots.register(\n { name: \'settings.general.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:88', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:89', }, { key: 'settings.header', @@ -1332,8 +1501,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1344,7 +1515,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.header\', () => ctx.slots.register(\n { name: \'settings.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:29', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:30', }, { key: 'settings.onboarding', @@ -1377,8 +1548,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1390,7 +1563,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.onboarding\', () => ctx.slots.register(\n { name: \'settings.onboarding\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:73', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:74', }, { key: 'settings.plugin.item', @@ -1411,8 +1584,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', @@ -1458,8 +1633,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1471,7 +1648,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.plugins.tab\', () => ctx.slots.register(\n { name: \'settings.plugins.tab\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:62', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:63', }, { key: 'settings.section', @@ -1504,8 +1681,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1519,7 +1698,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.section\', () => ctx.slots.register(\n { name: \'settings.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:53', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:54', }, { key: 'settings.trigger', @@ -1533,8 +1712,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1545,7 +1726,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.trigger\', () => ctx.slots.register(\n { name: \'settings.trigger\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-settings/src/client/contract/slots.ts:23', + source: 'packages/client/ui-settings/src/client/contract/slots.ts:24', }, { key: 'shell.overlay', @@ -1576,8 +1757,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1586,7 +1769,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:83', + source: 'packages/client/ui-layout/src/client/index.ts:85', }, { key: 'sidebar', @@ -1600,8 +1783,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1612,7 +1797,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar\', () => ctx.slots.register(\n { name: \'sidebar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-layout/src/client/index.ts:49', + source: 'packages/client/ui-layout/src/client/index.ts:51', }, { key: 'sidebar.brand.mark', @@ -1626,8 +1811,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1652,8 +1839,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1697,8 +1886,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1723,8 +1914,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1749,8 +1942,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1775,8 +1970,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', ], keyDomain: '', hookContext: '', @@ -1811,13 +2008,18 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Wire', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, read, skill, todo_write, web_fetch, web_search, write', hookContext: '', @@ -1867,13 +2069,18 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'CordisDynamicPluginRunId', ], standardProps: [ - 'useSessions: SnapshotSelectorHook', - 'useWorkspaces: SnapshotSelectorHook', - 'useSession: SnapshotSelectorHook', - 'sessionId: SessionId', - 'useProjection: UseProjection', + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', diff --git a/packages/extensions/cordis-client-runner/tests/guard.client.spec.ts b/packages/extensions/cordis-client-runner/tests/guard.client.spec.ts index 5d81d8798b..b79ac7d5a9 100644 --- a/packages/extensions/cordis-client-runner/tests/guard.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/guard.client.spec.ts @@ -16,7 +16,7 @@ import type { CordisDynamicPluginRunId, DynamicCordisPackage, } from '@deepseek-ai/dsh-api-remotes/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { dynamicCordisContext } from '../src/client/guard.ts' import type { DynamicCordisSlotLedgerRow } from '../src/client/guard.ts' diff --git a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts index 86ae5d9dad..4144d97bd0 100644 --- a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts @@ -16,9 +16,9 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { DynamicCordisInvokeResult } from '@deepseek-ai/dsh-api-remotes/client' -// Type-only: resolves `ctx.remote` and with it the `$on`/`$dispatch` surface. +// Type-only: resolves the `ctx.remote.$on` surface. import type {} from '@deepseek-ai/dsh-api-gateway/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import * as NodeHalf from '../src/index.ts' import * as Invariant from '../src/invariant.ts' import * as ClientHalf from '../src/client/index.ts' @@ -31,15 +31,6 @@ const USER_RUN = { agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' as const, hasClientHalf: true, } -/** - * Deliver one forwarded Host event the way the runtime's frame bridge does: the - * bridge hands `host/remote-event` to the Remote service, which fans it out to - * `$on` subscribers with the Host's own argument list. - */ -function forward(ctx: Context, event: string, payload: object): void { - ctx.remote.$dispatch(event, [payload]) -} - interface Bench { ctx: Context /** Source the host hands over for the next run. */ @@ -67,6 +58,8 @@ interface Bench { }[] /** Whether the namespace refuses the next render-failure report. */ reportRefused: { current: boolean } + /** Drive one forwarded Host event through the test-owned subscription table. */ + forward: (event: string, payload: object) => void /** * Report one entry crash the way the renderer's boundary does. Production calls * this from ui-renderer's boundary through the render host; a test has no React @@ -161,6 +154,11 @@ async function boot(): Promise { // this plugin's subscriptions, so registration order and delivery are all the // stub owes (api-gateway covers isolation and disposal on the real one). const listeners = new Map void)[]>() + const forward = (event: string, payload: object): void => { + for (const listener of [...listeners.get(event) ?? []]) { + (listener as (...args: readonly unknown[]) => void)(payload) + } + } const remote = { dynamicCordisRunner: namespace, $on: (event: string, listener: (...args: never[]) => void) => { @@ -172,11 +170,6 @@ async function boot(): Promise { if (at >= 0) bucket.splice(at, 1) } }, - $dispatch: (event: string, args: readonly unknown[]) => { - for (const listener of [...listeners.get(event) ?? []]) { - (listener as (...a: readonly unknown[]) => void)(...args) - } - }, } ctx.reflect.provide('remote', remote) ctx.reflect.provide('remote.dynamicCordisRunner', namespace) @@ -191,6 +184,7 @@ async function boot(): Promise { invokeThrow, renderFailures, reportRefused, + forward, crash: (slot, entry, abdicate, error) => { const core = (ctx.slots as unknown as { _core: { reportEntryError(key: string, entry: unknown, error: unknown, info: { abdicate: boolean }): void } @@ -213,7 +207,7 @@ describe('browser half', () => { const bench = await boot() await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN) expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true) - forward(bench.ctx, 'cordis/dynamic-retract', { + bench.forward('cordis/dynamic-retract', { pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, }) await bench.settle() @@ -350,7 +344,7 @@ describe('browser half', () => { it('answers a run request after the surface approves it', async () => { const bench = await boot() const request = 'rr-1' as ApprovalRequestId - forward(bench.ctx, 'cordis/request-run', { + bench.forward('cordis/request-run', { requestId: request, agentId: AGENT, pluginId: PLUGIN, @@ -383,7 +377,7 @@ describe('browser half', () => { it('drops the affordance when another page answers the request', async () => { const bench = await boot() const request = 'rr-2' as ApprovalRequestId - forward(bench.ctx, 'cordis/request-run', { + bench.forward('cordis/request-run', { requestId: request, agentId: AGENT, pluginId: PLUGIN, @@ -394,7 +388,7 @@ describe('browser half', () => { requiresApproval: true, }) await bench.settle() - forward(bench.ctx, 'cordis/request-run-resolved', { + bench.forward('cordis/request-run-resolved', { requestId: request, outcome: 'approved', }) await bench.settle() @@ -407,7 +401,7 @@ describe('browser half', () => { it('exposes the refusal and the load observer on the face', async () => { const bench = await boot() const request = 'rr-3' as ApprovalRequestId - forward(bench.ctx, 'cordis/request-run', { + bench.forward('cordis/request-run', { requestId: request, agentId: AGENT, pluginId: PLUGIN, @@ -454,7 +448,9 @@ describe('invariant companion', () => { expect(Invariant.name).toBe('cordis-client-runner-invariant') // No relation to audit here: the owned one is browser-local runner state. // An event this plugin declares nothing about: the bridge must not route it here. - expect(() => { (ctx.emit as (type: string) => void)('unrelated/event') }).not.toThrow() + expect(() => { + Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) + }).not.toThrow() await fiber.dispose() }) }) diff --git a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts index ee271411ea..02eac311df 100644 --- a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts @@ -19,7 +19,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { DYNAMIC_CLIENT_REDIRECTS } from '../src/client/evaluator.ts' import { DynamicCordisPackageRunner } from '../src/client/runtime.ts' import type { DynamicCordisClientHalf, DynamicCordisRenderFailure } from '../src/client/runtime.ts' @@ -290,7 +290,7 @@ describe('failure stages', () => { await bench.runner.load(half({ code: 'return { apply: (ctx) => { ctx.on("t/ping", () => console.error("after load")) } }', })) - ;(bench.ctx.emit as (type: string) => void)('t/ping') + Reflect.apply(bench.ctx.emit.bind(bench.ctx), undefined, ['t/ping']) const mirrored = logged.mock.calls.filter(call => String(call[0]).includes('logged an error')) logged.mockRestore() expect(mirrored).toHaveLength(1) diff --git a/packages/extensions/cordis-client-runner/tsconfig.json b/packages/extensions/cordis-client-runner/tsconfig.json index a93e5c0fb0..f1ac2c60ca 100644 --- a/packages/extensions/cordis-client-runner/tsconfig.json +++ b/packages/extensions/cordis-client-runner/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../client/modules" }, { - "path": "../../client/runtime" + "path": "../../client/ui-renderer" }, { "path": "../../client/ui-slots" diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index d9af46c3a0..bb3e9918ca 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -387,12 +387,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Host-only download surfaces (GET, no wire envelope); absent from IApiClient.', parameters: [], }, - { - signature: 'respond(message: ClientResponse): Promise', - description: 'Response entry for server requests; not a domain method.', - parameters: [{ name: 'message', description: 'Client response carrying the server request\'s rpcId.' }], - returns: 'Transport receipt for the response delivery.', - }, ], }, { @@ -1195,6 +1189,109 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'sessionController', + summary: 'Host service backing the generated `ctx.remote.session` namespace.', + description: 'Host service backing the generated `ctx.remote.session` namespace.', + methods: [ + { + signature: 'resolveAgent(sessionId: SessionId): Promise', + description: 'Resolve or resume one ordinary Session for another Host API domain.', + parameters: [{ name: 'sessionId', description: 'Session identity whose Agent owns the operation.' }], + returns: 'the live Agent or the stable Session-domain failure.', + }, + { + signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + description: 'Inspect one attached or persisted Session without activating its Agent.', + parameters: [{ name: 'sessionId', description: 'durable Session identity.' }, { name: 'signal', description: 'optional caller cancellation for persistence reads.' }], + returns: 'the current attached state or persisted header and event prefix.', + }, + { + signature: '@Remote(\'list\') async list(_request: SessionListRequest, signal: AbortSignal): Promise', + description: 'Read all visible Session rows without resuming an Agent.', + parameters: [{ name: '_request', description: 'reserved empty list request.' }, { name: 'signal', description: 'cancellation for persistence reads.' }], + returns: 'visible Session summaries ordered by activity.', + }, + { + signature: '@Remote(\'search\') search(request: SessionSearchRequest, signal: AbortSignal): Promise', + description: 'Search visible Session content without resuming an Agent.', + parameters: [{ name: 'request', description: 'literal message-content query.' }, { name: 'signal', description: 'cancellation for list and search reads.' }], + returns: 'authorized bounded Session search results.', + }, + { + signature: '@Remote(\'create\') create(request: SessionCreateRequest): Promise', + description: 'Create or idempotently adopt one ordinary Session.', + parameters: [{ name: 'request', description: 'requested identity, location, and Agent preset.' }], + returns: 'the Session identity and resolved preset when configured.', + }, + { + signature: '@Remote(\'models\') models(request: SessionModelsRequest): Promise', + description: 'Read model choices after explicitly resuming the addressed Session.', + parameters: [{ name: 'request', description: 'Session whose model state is requested.' }], + returns: 'the current selection and available model groups.', + }, + { + signature: '@Remote(\'selectModel\') selectModel(request: SessionSelectModelRequest): Promise', + description: 'Select one Session-local model after explicitly resuming the Session.', + parameters: [{ name: 'request', description: 'Session identity and requested model selection.' }], + returns: 'the normalized selection installed for the Session.', + }, + { + signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise', + description: 'Rename one Session after explicitly resuming it.', + parameters: [{ name: 'request', description: 'Session identity and proposed title.' }], + returns: 'the accepted title and durable event sequence.', + }, + { + signature: '@Remote(\'fork\') fork(request: SessionForkRequest): Promise', + description: 'Fork one cold-readable completed-turn prefix into a new Session.', + parameters: [{ name: 'request', description: 'source Session and optional event anchor.' }], + returns: 'the new Session identity.', + }, + { + signature: '@Remote(\'prompt\') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise', + description: 'Admit one prompt after explicitly resuming its Session.', + parameters: [{ name: 'request', description: 'Session identity, prompt content, source metadata, and delivery mode.' }, { name: 'signal', description: 'caller cancellation before prompt admission begins.' }], + returns: 'acknowledgement that the Agent accepted the prompt.', + }, + { + signature: '@Remote(\'attachment\') attachment(request: SessionAttachmentRequest): Promise', + description: 'Read one image proven reachable from the addressed Session log.', + parameters: [{ name: 'request', description: 'Session and attachment identities used for authorization.' }], + returns: 'the durable attachment reference and base64-encoded bytes.', + }, + { + signature: '@Remote(\'updateQueue\') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue', + description: 'Mutate one still-pending queue occurrence on a live Agent.', + parameters: [{ name: 'request', description: 'Session, queue item, and requested mutation.' }], + returns: 'acknowledgement that the queue mutation was applied.', + }, + { + signature: '@Remote(\'cancel\') cancel(request: SessionCancelRequest): SessionCancelValue', + description: 'Cancel one active Agent turn without dropping its pending inbox.', + parameters: [{ name: 'request', description: 'Session whose active Agent turn is cancelled.' }], + returns: 'acknowledgement that cancellation was requested.', + }, + { + signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise', + description: 'Read one cold-safe, message-aligned Session history page.', + parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence and presentation reads.' }], + returns: 'one chronological page and optional latest projections.', + }, + { + signature: '@Remote({ mode: \'stream\' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', + description: 'Follow one Session log from its opening or resume cursor.', + parameters: [{ name: 'request', description: 'durable address and last committed sequence already held by the caller.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], + returns: 'an opened cursor followed by gap-free event frames.', + }, + { + signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', + description: 'Stream a complete live-control baseline followed by replacement frames.', + parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], + returns: 'one complete baseline followed by live replacement frames.', + }, + ], + }, { key: 'sessionPersistence', summary: 'Durable append-only session storage.', @@ -2214,32 +2311,43 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and Typert providers.', description: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and Typert providers.', methods: [ + { + signature: 'readonly wireStream: TypertGatewayWireStream = { open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), failure: error => rpcError(error), }', + description: 'Carrier adapter shared by the WebSocket mux and local Host transports.', + parameters: [], + }, + { + signature: 'registerRemoteEvents(source: TypertRemoteEventSource): () => Promise', + description: 'Register the sole application-selected forwarded-event source.', + parameters: [{ name: 'source', description: 'stream factory installed by the Remote assembly.' }], + returns: 'disposer removing this source and cancelling its active streams.', + }, { signature: 'async invoke(request: InvokeRemoteRequest): Promise', description: 'Invoke one live Remote method through strict generated reflection or SRC markers.', parameters: [{ name: 'request', description: 'decoded endpoint and exact named wire arguments.' }], - returns: 'the validated business result.', + returns: 'the business result without output decoding.', throws: ['{@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.'], }, + { + signature: 'async stream(request: InvokeRemoteRequest): Promise>', + description: 'Open one live stream Remote method without assuming a physical carrier.', + parameters: [{ name: 'request', description: 'decoded endpoint and named wire arguments.' }], + returns: 'a cancellation-aware iterable over the business results.', + }, ], }, { key: 'userQuestions', - summary: '`ctx.userQuestions`: one active UI provider plus an `ask()` API.', - description: '`ctx.userQuestions`: one active UI provider plus an `ask()` API.', + summary: '`ctx.userQuestions`: validation plus the scoped answerer waterfall.', + description: '`ctx.userQuestions`: validation plus the scoped answerer waterfall.', methods: [ - { - signature: 'registerProvider(provider: UserQuestionProvider): () => void', - description: 'Register the UI provider. Only one provider may be active in a context.', - parameters: [{ name: 'provider', description: 'UI-side implementation that collects answers.' }], - returns: 'Disposer that unregisters this provider.', - }, { signature: 'async ask(request: AskUserQuestionRequest): Promise', - description: 'Ask the active UI provider and wait for the user\'s answer.\n\nWhen a caller supplies an agent, human interaction is valid only for the exact live runtime root. Runtime ownership, not durable session lineage, decides this boundary: an owned child has no human answerer and would block forever, while a lineage-bearing session resumed as a new runtime root may ask normally.', + description: 'Ask the scoped answerer waterfall and wait for the user\'s answer.\n\nWhen a caller supplies an agent, human interaction is valid only for the exact live runtime root. Runtime ownership, not durable session lineage, decides this boundary: an owned child has no human answerer and would block forever, while a lineage-bearing session resumed as a new runtime root may ask normally.', parameters: [{ name: 'request', description: 'Questions, owner agent, and abort signal.' }], returns: 'The answer chosen or typed by the human.', - throws: ['{UserQuestionError} code `CALLER_NOT_LIVE` when a supplied agent is not the registry\'s exact live instance, or `DELEGATED_CALLER` when that live agent is owned by another agent.'], + throws: ['{UserQuestionError} code `ASK_ABORTED` when the supplied signal is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent is not the registry\'s exact live instance, or `DELEGATED_CALLER` when that live agent is owned by another agent.'], }, ], }, @@ -2355,6 +2463,55 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'workspaceController', + summary: 'Host service backing the generated `ctx.remote.workspace` namespace.', + description: 'Host service backing the generated `ctx.remote.workspace` namespace.', + methods: [ + { + signature: '@Remote(\'create\') create(request: WorkspaceCreateRequest): Promise', + description: 'Create or idempotently resolve one Workspace over an existing directory.', + parameters: [{ name: 'request', description: 'directory path to register.' }], + returns: 'the Workspace and whether this call created it.', + }, + { + signature: '@Remote(\'rename\') rename(request: WorkspaceRenameRequest): Promise', + description: 'Rename one Workspace to a unique non-blank title.', + parameters: [{ name: 'request', description: 'Workspace identity and proposed title.' }], + returns: 'the updated Workspace projection.', + }, + { + signature: '@Remote(\'delete\') delete(request: WorkspaceDeleteRequest): Promise', + description: 'Remove one Workspace registration while retaining files and Sessions.', + parameters: [{ name: 'request', description: 'Workspace identity to remove.' }], + returns: 'deletion confirmation.', + }, + { + signature: '@Remote(\'insertBefore\') insertBefore(request: WorkspaceInsertBeforeRequest): Promise', + description: 'Move one Workspace within the registry display order.', + parameters: [{ name: 'request', description: 'moved Workspace and optional anchor.' }], + returns: 'the complete resulting Workspace order.', + }, + { + signature: '@Remote(\'insertSessionBefore\') insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise', + description: 'Move one accounted Session within a Workspace.', + parameters: [{ name: 'request', description: 'Workspace, Session, and optional anchor identities.' }], + returns: 'the updated Workspace projection.', + }, + { + signature: '@Remote(\'archiveSession\') archiveSession(request: WorkspaceArchiveSessionRequest): Promise', + description: 'Hide one known Session from Workspace grouping surfaces.', + parameters: [{ name: 'request', description: 'Session identity to archive.' }], + returns: 'the complete resulting archive set.', + }, + { + signature: '@Remote({ mode: \'stream\' }) follow(signal: AbortSignal): AsyncIterable', + description: 'Stream a complete Workspace baseline followed by ordered increments.', + parameters: [{ name: 'signal', description: 'generation cancellation.' }], + returns: 'baseline followed by ordered Workspace increments.', + }, + ], + }, { key: 'workspaceRegistry', summary: 'Durable workspace registry.', @@ -2520,13 +2677,53 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering). Awaited before the boundary commits — a listener that objects steers (`agent.steer(...)`) and the machine re-reads its inbox: fresh steering runs another step, none closes the turn. Data decides, so listener order cannot change the outcome. The inverse control (stop a tool loop early) is data too: a tool result carrying `concludesTurn` ends the turn at its step. The conclusion never short-circuits already-submitted next-step work: same-step `additionalContexts` or racing steering still runs, and the turn closes only when that inbox drains.', parameters: [{ name: 'payload', description: '.signal - the current turn\'s explicit abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }], }, + { + name: 'api-session/activity', + mode: 'emit', + signature: '\'api-session/activity\'(sessionId: SessionId, updatedAt: number): void', + summary: 'One user-authored durable message advanced Session list activity.', + description: 'One user-authored durable message advanced Session list activity.', + parameters: [{ name: 'sessionId', description: 'addressed Session identity.' }, { name: 'updatedAt', description: 'durable message time used for list ordering.' }], + }, + { + name: 'api-session/added', + mode: 'emit', + signature: '\'api-session/added\'(summary: SessionSummary): void', + summary: 'A Session became visible to Session list consumers.', + description: 'A Session became visible to Session list consumers.', + parameters: [{ name: 'summary', description: 'initial list row for the Session.' }], + }, + { + name: 'api-session/error', + mode: 'emit', + signature: '\'api-session/error\'(sessionId: SessionId, message: string): void', + summary: 'One Agent failed outside a durable turn position.', + description: 'One Agent failed outside a durable turn position.', + parameters: [{ name: 'sessionId', description: 'Agent and Session identity.' }, { name: 'message', description: 'user-safe failure chain.' }], + }, + { + name: 'api-session/removed', + mode: 'emit', + signature: '\'api-session/removed\'(sessionId: SessionId): void', + summary: 'A Session left the live Host registry.', + description: 'A Session left the live Host registry.', + parameters: [{ name: 'sessionId', description: 'removed Session identity.' }], + }, + { + name: 'api-session/status', + mode: 'emit', + signature: '\'api-session/status\'(sessionId: SessionId, running: boolean): void', + summary: 'One Agent changed running state.', + description: 'One Agent changed running state.', + parameters: [{ name: 'sessionId', description: 'Agent and Session identity.' }, { name: 'running', description: 'whether the Agent is running.' }], + }, { name: 'approval/request', mode: 'waterfall', - signature: '\'approval/request\'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise', + signature: '\'approval/request\'( this: Scoped, req: ApprovalRequestEvent, next: () => Promise, ): Promise', summary: 'Ask composed answerers for one decision.', - description: 'Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.', - parameters: [{ name: 'req', description: 'the pending decision (agent, tool identity, reason, signal).' }], + description: 'Ask composed answerers for one decision. Return an outcome to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.', + parameters: [{ name: 'req', description: 'pending approval request.' }], }, { name: 'authorization/settled', @@ -2824,6 +3021,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.', parameters: [{ name: 'exec', description: 'the execution object that traversed the pipeline.' }, { name: 'result', description: 'a deep-frozen snapshot of the final returned result.' }], }, + { + name: 'user-questions/request', + mode: 'waterfall', + signature: '\'user-questions/request\'( this: Scoped, request: AskUserQuestionRequestEvent, next: () => Promise, ): Promise', + summary: 'Ask composed answerers for structured user input.', + description: 'Ask composed answerers for structured user input. Return an answer to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.', + parameters: [{ name: 'request', description: 'pending user-question request.' }], + }, { name: 'webserver/index-inject', mode: 'emit', @@ -2890,7 +3095,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n}', }, { name: 'AgentCancelCause', @@ -2928,6 +3133,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ApiKeyRecord', declaration: 'export interface ApiKeyRecord {\n readonly kind: \'api-key\';\n readonly key?: string;\n readonly env?: Readonly>;\n}', }, + { + name: 'ApiSessionAgentError', + declaration: 'export type ApiSessionAgentError = Extract;', + }, + { + name: 'ApiSessionAgentResult', + declaration: 'export type ApiSessionAgentResult = {\n readonly agent: Agent;\n} | {\n readonly error: ApiSessionAgentError;\n};', + }, { name: 'ApprovalOutcome', declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';', @@ -2938,11 +3151,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ApprovalRequest', - declaration: 'export interface ApprovalRequest {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: CallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}', + declaration: 'export interface ApprovalRequest extends ApprovalRequestEvent {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: CallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}', }, { - name: 'ApprovalService', - declaration: 'export class ApprovalService extends Service {\n static Config: z;\n constructor(ctx: Context, public config: Config);\n setPolicy(agent: Agent, policy: ApprovalPolicy): void;\n async request(req: ApprovalRequest): Promise;\n overrideOf(session: Session): ApprovalPolicy | undefined;\n}', + name: 'ApprovalRequestEvent', + declaration: 'export interface ApprovalRequestEvent {\n readonly agent: Agent;\n readonly toolName: string;\n readonly callId?: CallId;\n readonly reason?: string;\n readonly signal?: AbortSignal;\n}', }, { name: 'AskUserQuestionAnswer', @@ -2966,7 +3179,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AskUserQuestionRequest', - declaration: 'export interface AskUserQuestionRequest {\n questions: AskUserQuestionItem[];\n agent?: Agent;\n signal?: AbortSignal;\n}', + declaration: 'export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {\n}', + }, + { + name: 'AskUserQuestionRequestEvent', + declaration: 'export interface AskUserQuestionRequestEvent {\n questions: AskUserQuestionItem[];\n agent?: Agent;\n signal?: AbortSignal;\n}', }, { name: 'AssembleContext', @@ -3060,14 +3277,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', }, - { - name: 'CancelOptions', - declaration: 'export interface CancelOptions {\n keepInbox?: boolean | undefined;\n}', - }, - { - name: 'ClientResponse', - declaration: 'export interface ClientResponse {\n type: \'client-response\';\n rpcId: RpcId;\n result: RpcResult;\n}', - }, { name: 'CodeBindingErrorClass', declaration: 'export interface CodeBindingErrorClass {\n name: string;\n memberNameProperty: string;\n}', @@ -3528,18 +3737,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ImageVariantId', declaration: 'export type ImageVariantId = Branded<\'ImageVariantId\'>;', }, - { - name: 'Inbox', - declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}', - }, - { - name: 'InboxNotifications', - declaration: 'export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n}', - }, - { - name: 'InboxTarget', - declaration: 'export type InboxTarget = \'next-turn\' | \'next-step\';', - }, { name: 'IndexInjection', declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};', @@ -3558,7 +3755,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvocationDescriptor', - declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', + declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly mode?: \'stream\';\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypertCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypertCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}', }, { name: 'InvocationParameterDescriptor', @@ -3844,6 +4041,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', }, + { + name: 'ModelCatalogFailure', + declaration: 'export interface ModelCatalogFailure {\n readonly id: string;\n readonly name: string;\n readonly message: string;\n}', + }, + { + name: 'ModelCatalogModel', + declaration: 'export interface ModelCatalogModel {\n readonly id: string;\n readonly name: string;\n readonly description?: string;\n readonly reasoning?: ModelReasoning;\n}', + }, { name: 'ModelMessageSource', declaration: 'export interface ModelMessageSource extends AssistantProvenance {\n kind: \'model\';\n}', @@ -3856,6 +4061,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ModelModalityMap', declaration: 'export interface ModelModalityMap {\n text: \'text\';\n image: \'image\';\n}', }, + { + name: 'ModelProviderGroup', + declaration: 'export interface ModelProviderGroup {\n readonly id: string;\n readonly name: string;\n readonly models: readonly ModelCatalogModel[];\n}', + }, + { + name: 'ModelReasoning', + declaration: 'export interface ModelReasoning {\n readonly efforts: readonly ModelReasoningEffort[];\n readonly defaultEffort?: string;\n}', + }, + { + name: 'ModelReasoningEffort', + declaration: 'export interface ModelReasoningEffort {\n readonly id: string;\n readonly name: string;\n readonly description?: string;\n}', + }, { name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', @@ -3940,6 +4157,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n contexts: AssembledContext[];\n tools: ToolSchema[];\n variables: Record;\n}', }, + { + name: 'PromptContentPart', + declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n};', + }, { name: 'PromptContext', declaration: 'export interface PromptContext {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', @@ -4046,16 +4267,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RpcErrorDetailsMap', - declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'model-unavailable\': {\n provider: string;\n model: string;\n };\n \'session-conflict\': {\n sessionId: SessionId;\n requestedCwd: string;\n existingCwd?: string;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'workspace-attach-failed\': {\n sessionId: SessionId;\n workspaceId: string;\n };\n \'workspace-not-found\': {\n workspaceId: string;\n };\n \'workspace-invalid-path\': {\n path: string;\n };\n \'workspace-name-conflict\': {\n name: string;\n };\n \'workspace-move-invalid\': {\n workspaceId: string;\n sessionId: SessionId;\n beforeSessionId?: SessionId;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-conflict\': {\n sessionId: SessionId;\n requestedPreset: string;\n existingPreset?: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n /* …truncated — full shape in source */', + declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'subagent-parent-unavailable\': {\n parentSessionId: SessionId;\n };\n \'subagent-not-found\': {\n parentSessionId: SessionId;\n childSessionId: SessionId;\n };\n \'subagent-catalog-diagnostic\': {\n parentSessionId: SessionId;\n childS /* …truncated — full shape in source */', }, { name: 'RpcId', declaration: 'export type RpcId = Branded<\'rpc-id\'>;', }, - { - name: 'RpcReceipt', - declaration: 'export type RpcReceipt = {\n accepted: true;\n} | {\n accepted: false;\n reason: \'not-pending\' | \'bad-response\';\n};', - }, { name: 'RpcResult', declaration: 'export type RpcResult = {\n ok: true;\n value: T;\n} | {\n ok: false;\n error: RpcError;\n};', @@ -4140,14 +4357,66 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ServerResponse', declaration: 'export interface ServerResponse {\n type: \'server-response\';\n rpcId: RpcId;\n result: RpcResult;\n}', }, + { + name: 'Session', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + }, + { + name: 'SessionAddress', + declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\';\n};', + }, + { + name: 'SessionAttachmentRequest', + declaration: 'export interface SessionAttachmentRequest {\n readonly sessionId: SessionId;\n readonly attachmentId: AttachmentIdType;\n}', + }, + { + name: 'SessionAttachmentValue', + declaration: 'export interface SessionAttachmentValue {\n readonly attachment: ImageAttachmentRef;\n readonly data: string;\n}', + }, { name: 'SessionAvailability', declaration: 'export type SessionAvailability = \'live\' | \'persisted\';', }, + { + name: 'SessionCancelRequest', + declaration: 'export interface SessionCancelRequest {\n readonly sessionId: SessionId;\n}', + }, + { + name: 'SessionCancelValue', + declaration: 'export interface SessionCancelValue {\n readonly accepted: true;\n}', + }, + { + name: 'SessionControlBaseline', + declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly jobs: Readonly>;\n readonly projections: Readonly>;\n}', + }, + { + name: 'SessionControlFrame', + declaration: 'export type SessionControlFrame = {\n readonly type: \'baseline\';\n readonly value: SessionControlBaseline;\n} | {\n readonly type: \'queue\';\n readonly sessionId: SessionId;\n readonly items: readonly SessionQueuedItem[];\n} | {\n readonly type: \'jobs\';\n readonly sessionId: SessionId;\n readonly jobs: readonly SessionJob[];\n} | ({\n readonly type: \'projection\';\n} & SessionProjectionUpdate);', + }, + { + name: 'SessionCreateRequest', + declaration: 'export interface SessionCreateRequest {\n readonly workspaceId?: WorkspaceId;\n readonly cwd?: string;\n readonly sessionId?: SessionId;\n readonly agentPreset?: string;\n}', + }, + { + name: 'SessionCreateValue', + declaration: 'export interface SessionCreateValue {\n readonly sessionId: SessionId;\n readonly agentPreset?: string;\n}', + }, + { + name: 'SessionError', + declaration: 'export type SessionError = {\n [Code in keyof SessionErrorDetailsMap]: {\n readonly code: Code;\n readonly message: string;\n readonly details: SessionErrorDetailsMap[Code];\n };\n}[keyof SessionErrorDetailsMap];', + }, + { + name: 'SessionErrorDetailsMap', + declaration: 'export interface SessionErrorDetailsMap {\n \'bad-request\': Record;\n cancelled: Record;\n \'session-not-found\': {\n readonly sessionId: SessionId;\n };\n \'model-unavailable\': {\n readonly provider: string;\n readonly model: string;\n };\n \'session-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedCwd: string;\n readonly existingCwd?: string;\n };\n \'invalid-time-zone\': {\n readonly value: string;\n };\n \'workspace-attach-failed\': {\n readonly sessionId: SessionId;\n readonly workspaceId: string;\n };\n \'workspace-not-found\': {\n readonly workspaceId: string;\n };\n \'agent-preset-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedPreset: string;\n readonly existingPreset?: string;\n };\n \'agent-preset-not-found\': {\n readonly agentPreset: string;\n readonly available: readonly string[];\n };\n \'agent-preset-invalid\': {\n readonly agentPreset: string;\n readonly reason: string;\n };\n \'agent-busy\': {\n readonly reason: string;\n };\n \'attachment-error\': {\n readonly reason: string;\n };\n \'queue-item-not-found\': {\n readonly itemId: MessageId;\n };\n \'steer-unavailable\': {\n readonly itemId: MessageId;\n };\n \'title-invalid\': {\n readonly sessionId: SessionId;\n };\n \'fork-unavailable\': {\n readonly sessionId: SessionId;\n /* …truncated — full shape in source */', + }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', }, + { + name: 'SessionEventEntry', + declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n readonly view?: SessionToolView;\n}', + }, { name: 'SessionEventMap', declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record;\n}', @@ -4208,10 +4477,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventWindow', declaration: 'export interface SessionEventWindow {\n session: SessionHeader;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}', }, + { + name: 'SessionFollowFrame', + declaration: 'export type SessionFollowFrame = {\n readonly type: \'opened\';\n readonly cursor: number;\n} | ({\n readonly type: \'event\';\n} & SessionEventEntry);', + }, + { + name: 'SessionFollowRequest', + declaration: 'export interface SessionFollowRequest {\n readonly address: SessionAddress;\n readonly afterSeq?: number;\n}', + }, + { + name: 'SessionForkRequest', + declaration: 'export interface SessionForkRequest {\n readonly sessionId: SessionId;\n readonly atSeq?: number;\n}', + }, { name: 'SessionForkSource', declaration: 'export type SessionForkSource = Session | SessionId;', }, + { + name: 'SessionForkValue', + declaration: 'export interface SessionForkValue {\n readonly sessionId: SessionId;\n}', + }, { name: 'SessionHeader', declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', @@ -4224,6 +4509,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionInspection', declaration: 'export interface SessionInspection {\n readonly meta: SessionHeader;\n readonly events: readonly SessionEvent[];\n}', }, + { + name: 'SessionJob', + declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n}', + }, { name: 'SessionLineageNode', declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}', @@ -4232,6 +4521,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionLineageTrace', declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});', }, + { + name: 'SessionListRequest', + declaration: 'export interface SessionListRequest {\n readonly cursor?: string;\n}', + }, + { + name: 'SessionListValue', + declaration: 'export interface SessionListValue {\n readonly items: readonly SessionSummary[];\n}', + }, { name: 'SessionLocation', declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', @@ -4240,6 +4537,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionLogSnapshot', declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n events: SessionEvent[];\n}', }, + { + name: 'SessionModels', + declaration: 'export interface SessionModels {\n readonly current: ModelSelection;\n readonly routable: boolean;\n readonly groups: readonly ModelProviderGroup[];\n readonly failures: readonly ModelCatalogFailure[];\n}', + }, + { + name: 'SessionModelsRequest', + declaration: 'export interface SessionModelsRequest {\n readonly sessionId: SessionId;\n}', + }, + { + name: 'SessionPage', + declaration: 'export interface SessionPage {\n readonly events: readonly SessionEventEntry[];\n readonly hasMore: boolean;\n readonly projections?: SessionProjectionsBlock;\n}', + }, + { + name: 'SessionPageRequest', + declaration: 'export interface SessionPageRequest {\n readonly address: SessionAddress;\n readonly throughSeq: number;\n readonly beforeSeq?: number;\n readonly maxMessages?: number;\n}', + }, { name: 'SessionPersistenceRevision', declaration: 'export type SessionPersistenceRevision = Branded<\'SessionPersistenceRevision\'>;', @@ -4260,10 +4573,38 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionMap', declaration: 'export interface SessionProjectionMap {\n}', }, + { + name: 'SessionProjectionsBlock', + declaration: 'export interface SessionProjectionsBlock {\n readonly asOfSeq: number;\n readonly values: SessionProjectionValues;\n}', + }, { name: 'SessionProjectionStateMap', declaration: 'export interface SessionProjectionStateMap {\n}', }, + { + name: 'SessionProjectionUpdate', + declaration: 'export interface SessionProjectionUpdate {\n readonly sessionId: SessionId;\n readonly key: string;\n readonly value: JsonValue;\n readonly seq: number;\n}', + }, + { + name: 'SessionProjectionValue', + declaration: 'export type SessionProjectionValue = JsonValue;', + }, + { + name: 'SessionProjectionValues', + declaration: 'export type SessionProjectionValues = Partial & Readonly>;', + }, + { + name: 'SessionPromptRequest', + declaration: 'export interface SessionPromptRequest {\n readonly requestId: SessionRequestId;\n readonly sessionId: SessionId;\n readonly mode: \'queue\' | \'steer\';\n readonly content: readonly PromptContentPart[];\n readonly clientTimeZone?: string;\n}', + }, + { + name: 'SessionPromptValue', + declaration: 'export interface SessionPromptValue {\n readonly accepted: true;\n}', + }, + { + name: 'SessionQueuedItem', + declaration: 'export interface SessionQueuedItem {\n readonly id: MessageId;\n readonly placement: \'queued\' | \'steering\' | \'context\';\n readonly message: {\n readonly id: MessageId;\n readonly content: readonly JsonValue[];\n };\n}', + }, { name: 'SessionRawArtifact', declaration: 'export interface SessionRawArtifact {\n readonly meta: SessionHeader;\n readonly filename: string;\n readonly content: string;\n}', @@ -4284,6 +4625,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceMentionCandidate', declaration: 'export interface SessionReferenceMentionCandidate extends SessionReferenceCandidate {\n mention: string;\n}', }, + { + name: 'SessionRenameRequest', + declaration: 'export interface SessionRenameRequest {\n readonly sessionId: SessionId;\n readonly title: string;\n}', + }, + { + name: 'SessionRenameValue', + declaration: 'export interface SessionRenameValue {\n readonly title: string;\n readonly seq: number;\n}', + }, + { + name: 'SessionRequestId', + declaration: 'export type SessionRequestId = Branded<\'session-request-id\'>;', + }, { name: 'SessionResultFilter', declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', @@ -4304,18 +4657,38 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSearchHit', declaration: 'export interface SessionSearchHit extends SessionRecord {\n bestMatch: SessionEventSearchHit;\n}', }, + { + name: 'SessionSearchItem', + declaration: 'export interface SessionSearchItem {\n readonly sessionId: SessionId;\n readonly snippet: string;\n}', + }, { name: 'SessionSearchPage', declaration: 'export interface SessionSearchPage {\n items: readonly T[];\n nextCursor?: SessionSearchCursor;\n}', }, { - name: 'SessionSearchRequest', - declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', + name: 'SessionSearchValue', + declaration: 'export interface SessionSearchValue {\n readonly items: readonly SessionSearchItem[];\n readonly hasMore: boolean;\n}', + }, + { + name: 'SessionSelectModelRequest', + declaration: 'export interface SessionSelectModelRequest extends ModelSelection {\n readonly sessionId: SessionId;\n}', + }, + { + name: 'SessionSelectModelValue', + declaration: 'export interface SessionSelectModelValue {\n readonly selected: ModelSelection;\n}', }, { name: 'SessionStartSource', declaration: 'export type SessionStartSource = \'startup\' | \'resume\' | \'clear\' | \'compact\';', }, + { + name: 'SessionSummary', + declaration: 'export interface SessionSummary {\n readonly sessionId: SessionId;\n readonly updatedAt: number;\n readonly running: boolean;\n readonly blank: boolean;\n readonly parentSessionId?: SessionId;\n readonly origin?: \'subagent\';\n readonly cwd?: string;\n readonly agentPreset?: string;\n readonly projections?: SessionProjectionsBlock;\n}', + }, + { + name: 'SessionSurface', + declaration: 'export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n}', + }, { name: 'SessionSurfaceSnapshot', declaration: 'export interface SessionSurfaceSnapshot {\n session: SessionHeader;\n capturedThroughSeq: number | null;\n events: SurfaceEvent[];\n}', @@ -4380,6 +4753,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionTitleUserMessage', declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}', }, + { + name: 'SessionToolCallView', + declaration: 'export type SessionToolCallView = (Omit & {\n readonly rawInput?: JsonValue;\n}) | TerminalCallView | DiffCallView;', + }, + { + name: 'SessionToolView', + declaration: 'export type SessionToolView = {\n readonly for: \'call\';\n readonly view: SessionToolCallView;\n} | {\n readonly for: \'result\';\n readonly view: ToolResultView;\n};', + }, + { + name: 'SessionUpdateQueueRequest', + declaration: 'export interface SessionUpdateQueueRequest {\n readonly sessionId: SessionId;\n readonly itemId: MessageId;\n readonly action: QueueAction;\n}', + }, + { + name: 'SessionUpdateQueueValue', + declaration: 'export interface SessionUpdateQueueValue {\n readonly accepted: true;\n}', + }, + { + name: 'SessionWireEvent', + declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: number[];\n readonly surfaceOp?: SurfaceOp;\n}', + }, { name: 'SettingsApplies', declaration: 'export type SettingsApplies = \'live\' | \'restart\';', @@ -4660,6 +5053,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SurfaceEventType', declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\';', }, + { + name: 'SurfaceIntent', + declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n}', + }, { name: 'SurfaceOp', declaration: 'export type SurfaceOp = \'append\' | {\n op: \'replace\';\n start: number;\n end: number;\n};', @@ -4952,6 +5349,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertEventModel', declaration: 'export interface TypertEventModel extends TypertDocumentation {\n readonly name: string;\n readonly mode?: string;\n readonly signature: string;\n}', }, + { + name: 'TypertGatewayWireStream', + declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', + }, { name: 'TypertMemberModel', declaration: 'export interface TypertMemberModel {\n readonly kind: \'property\' | \'method\' | \'getter\' | \'setter\' | \'call\' | \'construct\' | \'index\';\n readonly name: string;\n readonly signature: string;\n readonly summary?: string;\n readonly jsDoc?: string;\n}', @@ -4972,6 +5373,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertPackageRecord', declaration: 'export interface TypertPackageRecord {\n readonly package: string;\n readonly face: TypertFace;\n readonly key: string;\n readonly model: TypertPackageModel;\n}', }, + { + name: 'TypertRemoteEventContext', + declaration: 'export interface TypertRemoteEventContext {\n readonly value: Context;\n readonly subject: object;\n}', + }, + { + name: 'TypertRemoteEventDispatch', + declaration: 'export type TypertRemoteEventDispatch = TypertRemoteEventFrame | TypertRemoteEventInvocation;', + }, + { + name: 'TypertRemoteEventFrame', + declaration: 'export interface TypertRemoteEventFrame {\n readonly event: string;\n readonly args: readonly unknown[];\n}', + }, + { + name: 'TypertRemoteEventInvocation', + declaration: 'export interface TypertRemoteEventInvocation {\n readonly event: string;\n readonly request: object;\n readonly context: TypertRemoteEventContext;\n readonly resolve: (outcome: TypertRemoteEventOutcome) => void;\n readonly reject: (reason: unknown) => void;\n}', + }, + { + name: 'TypertRemoteEventOutcome', + declaration: 'export type TypertRemoteEventOutcome = {\n readonly kind: \'result\';\n readonly value: unknown;\n} | {\n readonly kind: \'next\';\n};', + }, + { + name: 'TypertRemoteEventSource', + declaration: 'export type TypertRemoteEventSource = (signal: AbortSignal) => AsyncIterable;', + }, { name: 'TypertSchemaFilter', declaration: 'export interface TypertSchemaFilter {\n readonly package?: string;\n readonly face?: TypertFace;\n}', @@ -4996,10 +5421,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'UserMessage', declaration: 'export interface UserMessage extends Message {\n readonly role: \'user\';\n}', }, - { - name: 'UserQuestionProvider', - declaration: 'export interface UserQuestionProvider {\n ask(request: AskUserQuestionRequest): Promise;\n}', - }, { name: 'VerifiedWebhookDelivery', declaration: 'export interface VerifiedWebhookDelivery {\n readonly kind: K;\n readonly source: WebhookSourceId;\n readonly deliveryId: WebhookDeliveryId;\n readonly event: WebhookEventOf;\n readonly receivedAt: number;\n}', @@ -5152,6 +5573,70 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkflowStopReason', declaration: 'export type WorkflowStopReason = \'completed\' | \'cancelled\' | \'error\';', }, + { + name: 'Workspace', + declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise;\n attachSession(sessionId: SessionId): Promise;\n insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise;\n detachSession(sessionId: SessionId): Promise;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', + }, + { + name: 'WorkspaceArchiveSessionRequest', + declaration: 'export interface WorkspaceArchiveSessionRequest {\n readonly sessionId: SessionId;\n}', + }, + { + name: 'WorkspaceArchiveValue', + declaration: 'export interface WorkspaceArchiveValue {\n readonly archivedSessionIds: readonly SessionId[];\n}', + }, + { + name: 'WorkspaceBaseline', + declaration: 'export interface WorkspaceBaseline {\n readonly items: readonly WorkspaceView[];\n readonly archivedSessionIds: readonly SessionId[];\n}', + }, + { + name: 'WorkspaceCreateRequest', + declaration: 'export interface WorkspaceCreateRequest {\n readonly path: string;\n}', + }, + { + name: 'WorkspaceCreateValue', + declaration: 'export interface WorkspaceCreateValue {\n readonly workspace: WorkspaceView;\n readonly created: boolean;\n}', + }, + { + name: 'WorkspaceDeleteRequest', + declaration: 'export interface WorkspaceDeleteRequest {\n readonly workspaceId: WorkspaceId;\n}', + }, + { + name: 'WorkspaceDeleteValue', + declaration: 'export interface WorkspaceDeleteValue {\n readonly deleted: true;\n}', + }, + { + name: 'WorkspaceFollowFrame', + declaration: 'export type WorkspaceFollowFrame = {\n readonly type: \'baseline\';\n readonly value: WorkspaceBaseline;\n} | WorkspaceFollowIncrement;', + }, + { + name: 'WorkspaceFollowIncrement', + declaration: 'export type WorkspaceFollowIncrement = {\n readonly type: \'upsert\';\n readonly workspace: WorkspaceView;\n} | {\n readonly type: \'remove\';\n readonly workspaceId: WorkspaceId;\n} | {\n readonly type: \'order\';\n readonly workspaceIds: readonly WorkspaceId[];\n} | {\n readonly type: \'archived\';\n readonly archivedSessionIds: readonly SessionId[];\n};', + }, + { + name: 'WorkspaceInsertBeforeRequest', + declaration: 'export interface WorkspaceInsertBeforeRequest {\n readonly workspaceId: WorkspaceId;\n readonly beforeWorkspaceId?: WorkspaceId;\n}', + }, + { + name: 'WorkspaceInsertSessionBeforeRequest', + declaration: 'export interface WorkspaceInsertSessionBeforeRequest {\n readonly workspaceId: WorkspaceId;\n readonly sessionId: SessionId;\n readonly beforeSessionId?: SessionId;\n}', + }, + { + name: 'WorkspaceOrderValue', + declaration: 'export interface WorkspaceOrderValue {\n readonly workspaceIds: readonly WorkspaceId[];\n}', + }, + { + name: 'WorkspaceRenameRequest', + declaration: 'export interface WorkspaceRenameRequest {\n readonly workspaceId: WorkspaceId;\n readonly title: string;\n}', + }, + { + name: 'WorkspaceValue', + declaration: 'export interface WorkspaceValue {\n readonly workspace: WorkspaceView;\n}', + }, + { + name: 'WorkspaceView', + declaration: 'export interface WorkspaceView {\n readonly workspaceId: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly sessionIds: readonly SessionId[];\n readonly createdAt: string;\n readonly updatedAt: string;\n}', + }, ] /** The inherited `ctx` API (cordis core + loader/hmr/timer), in curated order. */ diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index a3742af61e..c71fd086c3 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -32,12 +32,13 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-cordis-client-runner", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-tool", "@deepseek-ai/dsh-client-ui-sidebar" ], @@ -54,9 +55,10 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -66,10 +68,12 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/extensions/ui-cordis/src/client/index.ts b/packages/extensions/ui-cordis/src/client/index.ts index a757f08f15..239a378b4d 100644 --- a/packages/extensions/ui-cordis/src/client/index.ts +++ b/packages/extensions/ui-cordis/src/client/index.ts @@ -1,10 +1,13 @@ /** Cordis dynamic-plugin cards, inventory panel, business-view host, and `@pluginId` source. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-tool/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { InputTriggerService, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type {} from './events.ts' import { CordisActionRow } from './CordisActionRow.tsx' diff --git a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts index ec99bd2df2..2d335202ff 100644 --- a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts +++ b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts @@ -2,7 +2,7 @@ // call/result slice. import { describe, expect, it } from 'vitest' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { cordisActionCard, cordisDefineCard } from '../src/client/card-model.ts' const ARGS = '{"name":"clock","purpose":"顶栏时钟","code":{"client":"return {}","host":"harness.handle(\'now\', () => Date.now())"}}' diff --git a/packages/extensions/ui-cordis/tsconfig.json b/packages/extensions/ui-cordis/tsconfig.json index 049ad3185e..f2d68dff7b 100644 --- a/packages/extensions/ui-cordis/tsconfig.json +++ b/packages/extensions/ui-cordis/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../../client/runtime" + "path": "../../core/session" }, { "path": "../../client/ui-sidebar" @@ -32,6 +32,12 @@ { "path": "../../client/ui-primitives" }, + { + "path": "../../client/ui-renderer" + }, + { + "path": "../../client/ui-session" + }, { "path": "../../client/ui-slots" }, diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 272269eb99..2658e32f42 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -47,30 +47,23 @@ "dependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-jobs": "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-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "fflate": "^0.8.2", "zod": "^4.4.3" @@ -78,16 +71,12 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-storage": "workspace:^", - "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^" } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e21c58eb42..b0fd7abafe 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -3,46 +3,26 @@ * narrow RpcRequest

and echoes request.rpcId on the RpcResponse. */ -import { randomUUID } from 'node:crypto' -import { mkdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname } from 'node:path' -import { z as zod } from 'zod' import type { Context } from '@deepseek-ai/cordis' -import { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session' -import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' import { isUserInvocable } from '@deepseek-ai/dsh-skill' -import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' -import { - workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, -} from '@deepseek-ai/dsh-workspace' -// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import { InvalidPresetIdError, PresetExistsError, PresetMountError, PresetNotWritableError, resolveSessionPreset, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets' -import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, - ModelCatalogFailure, ModelProviderGroup, - ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, - QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, JobView, ToolEventView, - WorkspaceId, WorkspaceView, + ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, + SettingsNamespaceView, SubagentAddress, } from './api/index.ts' +import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types' +import { ApiSessionNotFound, buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, @@ -53,28 +33,11 @@ import { type SessionLogCompressionLevel, } from './session-export.ts' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' -import { - SESSION_SEARCH_RESULT_LIMIT, - SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, - truncateUnicodeCodePoints, -} from './api/session-search.ts' -// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. -import type {} from '@deepseek-ai/dsh-session-projection' -// Type-only: resolves `ctx.get('tasks')` to the background job registry. -import type {} from '@deepseek-ai/dsh-jobs' -import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' -// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). -import type {} from '@deepseek-ai/dsh-session-projection-cache' // GoalError narrows domain rejections to their stable codes at the wire boundary. import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' // Type-only edges: resolve the command-change stream and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' -// Type-only: the dynamic-package runner's forwarded-event declarations. Its -// client-safe `./types` subpath deliberately, not the package root — the root -// merges `ctx.dynamicCordisRunner`, and a dependency on that package would -// rebuild the api-remotes cycle this direction exists to avoid. -import type {} from '@deepseek-ai/dsh-cordis-host-runner/types' import type {} from '@deepseek-ai/dsh-skill' // The settings/credentials seams: brand guards run at this wire boundary; the // service reads stay optional (`ctx.get`) so a composition without either @@ -82,115 +45,11 @@ import type {} from '@deepseek-ai/dsh-skill' import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings' import { credentialRef } from '@deepseek-ai/dsh-credentials' -// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. -import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' -import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { ScopeKey } from '@deepseek-ai/dsh-scope' -import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' -// Side-effect type import: resolves the `approval/request` waterfall and -// `ctx.get('approval')` without a value dependency on the seam (optional composition). -import type {} from '@deepseek-ai/dsh-user-approval' -import { approvalResponsePayloadSchema } from './api/approvals.schema.ts' -import { imageLimitsProjectionSchema, sessionListMetadataProjectionSchema } from './api/sessions.schema.ts' -import { questionResponsePayloadSchema } from './api/questions.schema.ts' -import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts' -import { RpcId } from './api/rpc.ts' -import type { - AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest, -} from '@deepseek-ai/dsh-user-questions' -import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' +import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' -import { - ApiRemoteSessionNotFound as SessionNotFound, - ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership, - API_REMOTE_FORWARDED_EVENTS, - apiRemoteSubagentOwnershipError, - createApiRemoteAgentResolver, - hasApiRemoteSubagentOwner, - inspectApiRemoteSession, -} from '@deepseek-ai/dsh-api-remotes' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' -/** Page size when history is called without maxMessages. */ -const DEFAULT_MAX_MESSAGES = 50 - -/** Provider work budget: at most 100 calls and 2,000 inspected hits. */ -const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 - -/** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */ -const COLD_SUMMARY_BATCH_SIZE = 16 -/** Default maximum artifact size eligible for one cold blankness read. */ -export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 - -/** Conversation message event types (the pagination counting unit). */ -const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) - -/** Validate one prompt as a batch before publishing any durable image object. */ -async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) - } - const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image')) - let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - // admitEncodedImages returns one reference per image part in order. - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) -} - -/** Search durable content for an image reference, including nested tool results. */ -function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { - if (!Array.isArray(content)) return undefined - for (const value of content) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { type?: unknown; attachment?: unknown; content?: unknown } - if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { - const ref = block.attachment as ImageAttachmentRef - if (match(ref)) return ref - } - if (block.type === 'tool-result') { - const nested = imageBlockIn(block.content, match) - if (nested !== undefined) return nested - } - } - return undefined -} - -/** Search every durable event carrier that can own model-visible content. */ -function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { - const data = event.data as { - content?: unknown - message?: { content?: unknown } - inserted?: Array<{ content?: unknown }> - chunk?: { type?: unknown; block?: unknown } - } - const direct = imageBlockIn(data.content, match) - if (direct !== undefined) return direct - if (data.message !== undefined) { - const wrapped = imageBlockIn(data.message.content, match) - if (wrapped !== undefined) return wrapped - } - if (data.inserted !== undefined) { - for (const message of data.inserted) { - const inserted = imageBlockIn(message.content, match) - if (inserted !== undefined) return inserted - } - } - if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') { - return imageBlockIn([data.chunk.block], match) - } - return undefined -} - -/** Resolve the first reference matching one opaque id. */ -function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { - for (const event of events) { - const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId) - if (found !== undefined) return found - } - return undefined -} - /** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ @@ -215,120 +74,19 @@ function isAborted(signal: AbortSignal): boolean { return signal.aborted } -/** - * Message-boundary pagination: count maxMessages append-origin messages - * backwards from the window tail. Replacement copies never entered the - * conversation a reader sees — they restate a shadowed range for the model - * alone — so they consume no quota; the page stays one contiguous raw range, - * which keeps a compaction's log-only `compaction/summary` record on the same page as its - * replacement. The cut is the starting seq of the oldest message group (chunks - * group via sourceEventSeqs — never cut mid-message). The tail page naturally - * includes the in-progress partial. - */ -function paginate( - events: readonly SessionEvent[], - beforeSeq: number | undefined, - maxMessages: number, -): { events: SessionEvent[]; hasMore: boolean } { - const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq) - let count = 0 - let cut = 0 - for (let i = window.length - 1; i >= 0; i--) { - const event = window[i] as SessionEvent - if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue - count++ - const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs - let groupStart = event.seq - if (sources !== undefined) { - for (const source of sources) { - if (source < groupStart) groupStart = source - } - } - if (count >= maxMessages) { - cut = groupStart - break - } - } - const page = window.filter(event => event.seq >= cut) - return { events: page, hasMore: cut > 0 } -} - /** Wrap an ok result echoing the request's rpcId. */ function ok(request: RpcRequest, value: T): RpcResponse { return { rpcId: request.rpcId, result: { ok: true, value } } } -/** - * Build the provider/model catalog over every registered route. Shared by the - * session-scoped `session.models` and host-scoped `llm.models`. Catalog - * membership stays advisory: an unlisted session selection remains valid for - * provider dispatch, but is not injected back into the selector after its - * owning catalog stops advertising it. Per-provider failures ride `failures` - * without failing the sound groups; groups that advertise nothing are dropped. - */ -async function buildModelCatalog(ctx: Context): Promise<{ - groups: ModelProviderGroup[] - failures: ModelCatalogFailure[] -}> { - const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => { - try { - const models = await ctx.llm.listModels(provider.id) - const entries = await Promise.all(models.map(async (model) => { - const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id) - const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined - ? undefined - : { - efforts: resolved.reasoning.efforts.map(effort => ({ - id: effort.id, - name: effort.name, - ...effort.description === undefined - ? {} - : { description: effort.description }, - })), - ...resolved.reasoning.defaultEffort === undefined - ? {} - : { defaultEffort: resolved.reasoning.defaultEffort }, - } - return { - id: model.id, - name: model.name, - ...model.description === undefined ? {} : { description: model.description }, - ...reasoning === undefined ? {} : { reasoning }, - } - })) - const group: ModelProviderGroup = { - id: provider.id, - name: provider.name, - models: entries, - } - return { kind: 'group' as const, group } - } catch (error: unknown) { - const failure: ModelCatalogFailure = { - id: provider.id, - name: provider.name, - message: error instanceof Error ? error.message : String(error), - } - return { kind: 'failure' as const, failure } - } - })) - return { - groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []).filter(group => group.models.length > 0), - failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), - } -} - /** Wrap an error result echoing the request's rpcId. */ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } /** - * The RPC refusal a preset failure becomes, or undefined when the failure is - * about something else. - * - * Both the session-create path and the switch path can be handed the same two - * failures, and a client that has to branch on the code needs them worded the - * same from either. + * Map an agent-preset selection failure onto its stable RPC refusal, or leave + * unrelated failures to the caller. * @param request - the request being answered. * @param error - the thrown value. * @returns the refusal, or undefined when the caller should keep handling. @@ -351,93 +109,6 @@ function presetFailure(request: RpcRequest, error: unknown): RpcRespons return undefined } -/** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */ -class FrameQueue { - private buffer: F[] = [] - private waiter: (() => void) | undefined - private done = false - - push(item: F): void { - if (this.done) return - this.buffer.push(item) - this.waiter?.() - } - - end(): void { - this.done = true - this.waiter?.() - } - - async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator { - const onAbort = (): void => { this.end() } - signal.addEventListener('abort', onAbort, { once: true }) - try { - while (true) { - while (this.buffer.length > 0) yield this.buffer.shift() as F - if (this.done || signal.aborted) return - await new Promise((resolve) => { this.waiter = resolve }) - this.waiter = undefined - } - } finally { - signal.removeEventListener('abort', onAbort) - cleanup() - } - } -} - -/** - * Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable - * frames — approval/question requested — mint their stable id in their - * pending registries instead). - */ -function frame(payload: F): RpcRequest { - return { rpcId: RpcId(randomUUID()), payload } -} - -/** - * Narrow one allowlisted host event's argument list to the JSON values the - * wrapper frame carries. A rejected argument is an allowlist mistake (the - * forwarded path applies no projection), not hostile input, so it throws rather - * than degrading to a lossy frame. The throw surfaces where the forwarding - * listener runs, so the emitter's own listener containment logs it and drops - * that frame — loud in the Host log, not at load or at the emit. Exported for - * the test that owns this decision: every currently allowlisted event has a - * statically JSON-safe payload, so a type-legal `ctx.emit` cannot reach the - * rejection branch. - * @param event - forwarded host event name, named in the failure. - * @param args - the emitter's argument list. - * @returns the same arguments typed as JSON values. - */ -export function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] { - for (const [index, arg] of args.entries()) { - if (!isJsonValue(arg)) { - throw new Error(`forwarded host event "${event}" argument ${index} is not lossless JSON data`) - } - } - return args as JsonValue[] -} - -/** Queue the subscription baseline frame. */ -function subscribeSession(queue: FrameQueue>, session: Session): void { - queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) -} - -/** - * Project registry snapshots onto the wire view, dropping the three internal - * fields {@link JobView} documents as absent. - */ -function jobViews(snapshots: readonly JobSnapshot[]): JobView[] { - return snapshots.map(job => ({ - id: job.id, - kind: job.kind, - label: job.label, - status: job.status, - ...job.detail === undefined ? {} : { detail: job.detail }, - startedAt: job.startedAt, - ...job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }, - })) -} - /** * Whether the session's conversation has started: no turn has run yet (a * turn is one model-loop execution). Standalone plugin events — command @@ -449,122 +120,6 @@ function sessionBlank(session: Session): boolean { return !session.events.some(event => event.type === 'turn/start') } -/** Advance the Session-list hint projection by one committed event. */ -function applySessionListMetadata(state: SessionListMetadata, event: SessionEvent): SessionListMetadata { - const blank = state.blank && event.type !== 'turn/start' - const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user' - ? event.time - : state.lastPromptAt - return blank === state.blank && lastPromptAt === state.lastPromptAt - ? state - : { blank, lastPromptAt } -} - -/** Fold exact list metadata for an attached Session. */ -function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata { - let state: SessionListMetadata = { blank: true, lastPromptAt: null } - for (const event of events) state = applySessionListMetadata(state, event) - return state -} - -/** Sort by creation or latest human prompt, whichever is newer. */ -function sessionListUpdatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number { - return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0) -} - -/** Shared Session-header projection for list baselines and creation frames. */ -function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): { - parentSessionId?: SessionId - origin?: 'subagent' - cwd?: string - agentPreset?: string -} { - // The preset comes from the log, not the header: a session that switched - // while blank ran its turns under the newer composition, and a picker - // showing the creation-time value would contradict what the model saw. - const agentPreset = resolveSessionPreset({ header, events }) - return { - ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }, - ...header.origin === undefined ? {} : { origin: header.origin }, - ...header.cwd === undefined ? {} : { cwd: header.cwd }, - ...agentPreset === undefined ? {} : { agentPreset }, - } -} - -/** SessionSummary projection for attached (in-memory) sessions. */ -function summarize(session: Session, running: boolean): SessionSummary { - const metadata = sessionListMetadata(session.events) - return { - sessionId: session.id, - updatedAt: sessionListUpdatedAt(session.header, metadata), - running, - blank: metadata.blank, - ...sessionListFields(session.header, session.events), - } -} - -/** - * Verify a possibly blank cold Session only when its physical artifact passes - * the configured per-Session size check. A stale `blank: true`, an - * absent cache row, a large or location-less artifact, and read failures all - * resolve to visible (`false`); listing must never hide a conversation on a - * cache hint or an unavailable optimization. - */ -async function probeColdSessionMetadata( - ctx: Context, - persistence: SessionPersistence, - meta: SessionHeader, - maxBytes: number, - signal?: AbortSignal, -): Promise { - if (maxBytes === 0) return undefined - signal?.throwIfAborted() - const location = persistence.locate(meta) - if (location === undefined) return undefined - signal?.throwIfAborted() - let size: number - try { - size = (await stat(location.path)).size - } catch { - signal?.throwIfAborted() - return undefined - } - if (size > maxBytes) return undefined - try { - const { events } = await persistence.readFrom(meta.id, 0, signal) - signal?.throwIfAborted() - return sessionListMetadata(events) - } catch (error) { - signal?.throwIfAborted() - ctx.logger.warn(`session.list: blank probe for "${meta.id}" failed (serving it as visible): ${String(error)}`) - return undefined - } -} - -/** SessionSummary projection for a cold persisted Session. */ -async function summarizeCold( - ctx: Context, - persistence: SessionPersistence, - meta: SessionHeader, - metadata: SessionListMetadata | undefined, - blankProbeMaxBytes: number, - signal?: AbortSignal, -): Promise { - const probed = metadata?.blank === false - ? undefined - : await probeColdSessionMetadata(ctx, persistence, meta, blankProbeMaxBytes, signal) - return { - sessionId: meta.id, - updatedAt: sessionListUpdatedAt(meta, probed ?? metadata), - running: false, - blank: metadata?.blank === false ? false : probed?.blank ?? false, - // Header-only: reading the log for a blank-window preset switch would - // defeat the same index read, and attaching the session replaces this row - // with `summarize()`, which resolves the switch from the events. - ...sessionListFields(meta), - } -} - /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */ function directoryError(error: unknown): RpcError { if (error instanceof DirectoryPickerError) { @@ -573,24 +128,11 @@ function directoryError(error: unknown): RpcError { return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } } -/** Resolved Agent model and project-directory defaults consumed by the API implementation. */ +/** Deployment metadata and Host integrations consumed by the API implementation. */ export interface ApiProxyDefaults { - /** - * The model selection a session starts from when its own log names none. Read on - * every access rather than captured, so a default saved during this process - * reaches the sessions that have not run a turn yet. - */ + /** Current deployment model selection reported by `host.describe`. */ defaultModelSelection: () => ModelSelection - /** - * Record a selection as the new default. Either absent, or a closure that - * may itself decline — the gateway plugin always passes one, and it no-ops - * when the deployment mounts no settings provider or when the write races - * service teardown. A switch then stays process-local. A rejection is - * reported and swallowed: the switch already applies to its own session, - * and undoing it because storage failed would be the worse outcome. - */ - saveDefaultModelSelection?: (selection: ModelSelection) => Promise - /** Default project directory for new sessions whose create request carries no cwd. */ + /** Project hint reported by `host.describe`; must match Session Controller's default cwd. */ cwd: string /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise @@ -598,8 +140,6 @@ export interface ApiProxyDefaults { openTextFile?: (path: string, signal: AbortSignal) => Promise /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */ sessionExportCompressionLevel?: SessionLogCompressionLevel - /** Maximum artifact size eligible for one cold blankness read. */ - coldBlankProbeMaxBytes?: number /** * Whether handing a path to the native opener can work at all — the * `hasDocument` capability the preset roster reports, and the switch @@ -610,235 +150,6 @@ export interface ApiProxyDefaults { canOpenPath?: () => boolean } -/** The tool/call payload fields the presenter path reads. */ -interface ToolCallData { callId: string; name: string; arguments: string } -/** - * One outstanding approval question: the stable server-request id, the frame - * material replayed to late mux subscribers, and the resolver that settles the - * answerer's promise back into `ctx.approval`. - */ -interface PendingApproval { - rpcId: RpcId - sessionId: SessionId - approvalId: ApprovalRequestId - toolName: string - callId?: CallId - reason?: string - resolve(outcome: ApprovalOutcome): void -} - -/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */ -function requestedFrame(pending: PendingApproval): RpcRequest { - return { - rpcId: pending.rpcId, - payload: { - type: 'approval/requested', - sessionId: pending.sessionId, - approvalId: pending.approvalId, - toolName: pending.toolName, - ...pending.callId === undefined ? {} : { callId: pending.callId }, - ...pending.reason === undefined ? {} : { reason: pending.reason }, - }, - } -} - -/** One host-owned question wait, addressed by the stable server-request id. */ -interface PendingQuestion { - rpcId: RpcId - sessionId: SessionId - questions: AskUserQuestionItem[] - resolve: (answer: AskUserQuestionAnswer) => void - reject: (error: UserQuestionError) => void - signal?: AbortSignal - onAbort?: () => void -} - -/** Validate one answer batch against the exact question request it resolves. */ -function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean { - if (payload.sessionId !== pending.sessionId) return false - const answers = payload.answer.answers - if (answers.length !== pending.questions.length) return false - return answers.every((answer, index) => { - const question = pending.questions[index] as AskUserQuestionItem - if (answer.id !== question.id) return false - if (new Set(answer.selected).size !== answer.selected.length) return false - const custom = answer.custom?.trim() - if (custom !== undefined && custom === '') return false - if (question.multiSelect !== true) { - if (custom !== undefined && answer.selected.length > 0) return false - if (answer.selected.length > 1) return false - } - const labels = new Set(question.options?.map(option => option.label) ?? []) - return answer.selected.every(label => labels.has(label)) - }) -} - -/** - * Compute the render intent for a tool/call or tool/result event through the - * presenters registered at this moment; every other event type gets none. A - * result's presenter needs its call's parsed args — `argsFor` supplies them - * (live: the per-session call table; history: an in-page backscan), returning - * undefined when the pairing is unavailable (e.g. the call fell off the page), - * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall: - * the client's documented default (generic JSON card) covers every miss. - */ -function viewFor( - ctx: Context, - event: SessionEvent, - argsFor: (callId: string) => unknown, - // Presenters live with the definitions, and definitions live in the scope - // chain: a preset registers its tools into its standing layer. A live agent - // is a scope whose chain passes through its preset; a cold read passes the - // preset's standing key directly — no agent, no resume. An undefined scope - // sees only the global layer, which is the pre-preset deployment shape. - scope?: ScopeKey, -): ToolEventView | undefined { - try { - if (event.type === 'tool/call') { - const { name, arguments: raw } = event.data as ToolCallData - const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw)) - return view === undefined ? undefined : { for: 'call', view } - } - if (event.type === 'tool/result') { - const { message, meta } = event.data - const [result] = message.content - const callId = message.source.callId - const call = argsFor(callId) as { name: string; args: unknown } | undefined - if (call === undefined) return undefined - const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, { - content: result.content, - isError: result.isError === true, - ...meta === undefined ? {} : { meta }, - }) - return view === undefined ? undefined : { for: 'result', view } - } - } catch (error: unknown) { - // A throwing presenter (or unparseable arguments) must not break delivery; - // the event still ships, just without a view. - console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`) - } - return undefined -} - -/** - * Resolve a tool/result's call pairing by scanning a window of events backwards - * for the matching tool/call. Used by the history path (the page is the - * window — a cross-page pairing soft-falls to no view) and by live-path table - * misses after a reconnect-eviction. - */ -function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined { - for (let i = events.length - 1; i >= 0; i--) { - const event = events[i] as SessionEvent - if (event.type !== 'tool/call') continue - const data = event.data as ToolCallData - if (data.callId !== callId) continue - try { - return { name: data.name, args: JSON.parse(data.arguments) } - } catch { - // Unparseable stored arguments: same soft-fall as a live parse failure. - return undefined - } - } - return undefined -} - -/** Render one detached history page through the same presenter path as ordinary history. */ -function historyPage( - ctx: Context, - events: readonly SessionEvent[], - beforeSeq: number | undefined, - maxMessages: number | undefined, - scope?: ScopeKey, -): { events: HistoryEntry[]; hasMore: boolean } { - const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) - return { - events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope) - return { event, ...view === undefined ? {} : { view } } - }), - hasMore: page.hasMore, - } -} - -/** - * The projection baseline for one history tail page: the registry's - * watermark-cache snapshot — one fully synchronous read (no await between the - * page slice and this), so all values and `asOfSeq` form a single consistent - * cut and `asOfSeq` equals the window tail event seq. The carrier holds zero - * domain knowledge (each value passed its unit's own schema inside the - * registry). An absent registry means the deployment has no projection seam: - * the whole block is absent and clients treat every key as capability-absent. - */ -/** - * Which session a transcript read is served from. An attached session is the - * live object and keeps appending, so its events and projection baseline are - * read together in one synchronous step; a detached one is already a frozen - * inspection. - */ -type HistorySource = - | { readonly kind: 'attached'; readonly session: Session } - | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] } - -function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined { - const registry = ctx.get('sessionProjections') - if (registry === undefined) return undefined - return registry.snapshot(session) -} - -/** - * The projection baseline of one session.list row, fail-soft: attached - * sessions cut the registry's live watermark cache; cold sessions view the - * persisted projection cache's identity-checked stored rows (zero log loads - * either way — the listing use case the cache exists for). The block shape - * (values + asOfSeq) matches the history tail's, so a client seeds its - * value store under the same higher-seq-wins rule. Any failure — and an - * empty value set — yields an absent block: a listing without projections - * is degraded, never broken. - */ -function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined { - try { - const block = session !== undefined - ? ctx.get('sessionProjections')?.snapshot(session) - : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta) - return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined - } catch (error) { - ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`) - return undefined - } -} - -/** Projection baseline for a detached history tail without Agent activation. */ -function detachedProjectionsFor( - ctx: Context, - events: readonly SessionEvent[], -): SessionProjectionsBlock | undefined { - const registry = ctx.get('sessionProjections') - if (registry === undefined) return undefined - return registry.restore({}, events, 0).snapshot -} - -/** - * Best-effort projections for one subagent history page, fail-soft like - * {@link listProjectionsFor}: a registered unit throwing on a corrupt payload - * never blocks transcript reading — the page is served without the block. - * @param ctx - context carrying the logger for the degradation warning. - * @param childSessionId - the child whose page is being decorated. - * @param compute - the arm-specific fold (live watermark or detached restore). - * @returns the projections block, or undefined when the fold failed. - */ -function subagentHistoryProjections( - ctx: Context, - childSessionId: SessionId, - compute: () => SessionProjectionsBlock | undefined, -): SessionProjectionsBlock | undefined { - try { - return compute() - } catch (error) { - ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`) - return undefined - } -} - /** Map continuation admission failures without exposing provider details. */ function subagentPromptError( request: RpcRequest<{ childSessionId: SessionId }>, @@ -966,96 +277,15 @@ function presetError(agentPreset: string, error: unknown): RpcError { return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} } } -class AgentPresetConflict extends Error { - constructor( - readonly sessionId: SessionId, - readonly requestedPreset: string, - readonly existingPreset: string | undefined, - ) { - super( - existingPreset === undefined - ? `session "${sessionId}" records no agent preset, so it cannot be adopted under one; ` - + 'a deployment composing no roster records none on any session — ' - : `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; ` - + `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`, - ) - } -} - -/** Requested identity already belongs to a session with another project cwd. */ -class SessionCwdConflict extends Error { - constructor( - readonly sessionId: SessionId, - readonly requestedCwd: string, - readonly existingCwd: string | undefined, - ) { - super( - `session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; ` - + `requested ${JSON.stringify(requestedCwd)}`, - ) - } -} - -/** An explicit Host naming operation would duplicate another Workspace title. */ -class WorkspaceNameConflictError extends Error { - constructor(readonly workspaceName: string) { - super(`workspace name '${workspaceName}' is already in use`) - this.name = 'WorkspaceNameConflictError' - } -} - -/** Shared workspace-not-found error response of the workspace.* mutation rows. */ -function workspaceNotFound(request: RpcRequest, workspaceId: string): RpcResponse { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${workspaceId}" not found`, - details: { workspaceId }, - }) -} - -/** Wire projection of one workspace entity (the workspace.* value row). */ -function workspaceView(workspace: Workspace): WorkspaceView { - return { - workspaceId: workspace.id, - path: workspace.path, - title: workspace.title, - sessionIds: [...workspace.sessionIds], - createdAt: workspace.createdAt, - updatedAt: workspace.updatedAt, - } -} - -/** Wire projection of the durable record carried by `domain/changed`. */ -function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView { - const record: WorkspaceRecord = workspaceRecord.parse(value) - return { - workspaceId: workspaceId as WorkspaceId, - path: record.path, - title: record.title, - sessionIds: [...record.sessionIds], - createdAt: record.createdAt, - updatedAt: record.updatedAt, - } -} - /** * Implement ApiProxy over a composed host context. - * @param ctx - a context with the Host spine and Workspace registry mounted. + * @param ctx - a context with the Host spine mounted. * @param defaults - host routing and project-directory defaults. * @returns the ApiProxy implementation. */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL - const coldBlankProbeMaxBytes = defaults.coldBlankProbeMaxBytes - ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES - /** The seed model each create/resume declares; re-read so it never goes stale. */ - const agentOptions = (): AgentOptions => { - const { provider, model } = defaults.defaultModelSelection() - return { provider, model } - } - type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection } - const selections = new WeakMap() /** * Serializes `agentPreset.select` per session. Two concurrent selects both * pass the blank check, and the second `unmountPresetFor` then finds nothing @@ -1064,477 +294,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * not enforcement: the wire is reachable directly. */ const presetSwitches = new Map>() - /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ - const sessionCreations = new Map>() - /** Serializes path ownership and explicit title checks with Workspace mutations. */ - let workspaceCreationChain = Promise.resolve() - const pendingQuestions = new Map() - const pendingApprovals = new Map() - const muxQueues = new Set>>() - const imageAdmissionChains = new WeakMap>() + const agentFor = (sessionId: SessionId) => + ctx.sessionController.resolveAgent(sessionId) - /** Serialize image admission with model selection for one agent. */ - function serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { - const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation) - imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined)) - return result - } - - /** - * Install or return the session-local model selection that prompt assembly snapshots. - * - * Precedence, resolved on EVERY read rather than seeded once: a selection - * made in this process, else the session's own latest logged request/header, - * else the live Agent default. Re-reading keeps the two tiers exact in both - * directions: a session with a recorded request derives its selection from - * its log, while a blank session (New Session reuses one rather than minting - * another) reads any default saved after it was created. There is no create-time - * per-session override tier on this wire — if one returns (a create-options - * contribution), it must fold in between the selection and the log. - */ - function selectionFor(agent: Agent): WebModelSelectionRef { - const installed = selections.get(agent) - if (installed !== undefined) return installed - let picked: ModelSelection | undefined - const selection: WebModelSelectionRef = { - get current(): ModelSelection { - if (picked !== undefined) return picked - // Incrementally folded by the session, so a per-step read costs - // O(new events) rather than a rescan. - const logged = agent.session.requestHeader()?.config - if (logged === undefined) return defaults.defaultModelSelection() - return { - provider: logged.provider, - model: logged.model, - ...logged.reasoningEffort === undefined - ? {} - : { reasoningEffort: logged.reasoningEffort }, - } - }, - set current(next: ModelSelection) { - picked = next - }, - assembled: undefined, - } - installModelSelection(agent.ctx, selection) - selections.set(agent, selection) - return selection - } - - /** Pre-publication setup used by both fresh and resumed Web agents. */ - function installSelection(agentCtx: Context): void { - const agent = agentCtx.agent - if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent') - selectionFor(agent) - } - - /** - * Reject an attempt to run an existing session under a different preset. - * - * A caller that names no preset always adopts the session as it is, so the - * common paths — reconnecting, resuming, retrying a create — are unaffected. - * @param sessionId - the identity being adopted. - * @param requested - the preset the request named, if any. - * @param existing - the preset the session RUNS, if any; both callers resolve - * it from the log, which differs from the creation header once a blank - * session has switched. - * @throws when both are present and differ. - */ - function assertPresetUnchanged( - sessionId: SessionId, - requested: string | undefined, - existing: string | undefined, - ): void { - if (requested === undefined || requested === existing) return - throw new AgentPresetConflict(sessionId, requested, existing) - } - - /** - * Resolve the preset an agent will be composed from, and the setup that - * installs it. - * - * The id is resolved BEFORE the session exists because the session boundary - * snapshots `meta` before asynchronous setup begins — a preset discovered - * during setup could never reach the header. Mounting still happens in - * setup, where a failure rolls the whole creation back rather than leaving a - * published session whose capabilities are half-installed. - * - * A deployment with no preset roster composes nothing and every session - * shares the host composition, which is the behavior before presets existed. - * @param presetId - the requested preset, or `undefined` for the default. - * @returns the id to record on the header (absent without a roster) and the setup callback. - * @throws when the roster supplies no such preset. - */ - async function composeAgent(presetId: string | undefined): Promise<{ - agentPreset?: string - setup: (agentCtx: Context) => Promise - }> { - const presets = ctx.get('agentPresets') - if (presets === undefined) { - return { - setup: (agentCtx: Context) => { - installSelection(agentCtx) - return Promise.resolve() - }, - } - } - const resolvedId = (await presets.resolve(presetId)).id - return { - agentPreset: resolvedId, - setup: async (agentCtx: Context) => { - installSelection(agentCtx) - await presets.mount(agentCtx, resolvedId) - }, - } - } - - const hasSubagentOwner = ( - session: Pick, - agent: Agent | undefined, - ): boolean => hasApiRemoteSubagentOwner(ctx, session, agent) - const subagentOwnershipError = (sessionId: SessionId): RpcError => - apiRemoteSubagentOwnershipError(sessionId) - const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> => - inspectApiRemoteSession(ctx, sessionId) - // Cold resume composes the preset the session recorded, for the same reason - // `session.create` does: its history was produced under that composition. - // Every generic entry point — prompt, models, commands — arrives here, so - // leaving it out meant a session opened after a restart ran on host tools - // and the deployment persona. Resolved from the LOG, not the header: a - // session that switched while blank ran its turns under the newer - // composition, and the header is written once at creation. Reading the - // header here would silently undo the switch on the next restart and - // restore that history under the old tool set. - const agentFor = createApiRemoteAgentResolver(ctx, { - agentOptions, - setup: async ({ meta, events }) => - (await composeAgent(resolveSessionPreset({ header: meta, events }))).setup, - }) - - /** Send one transient frame to every connected mux consumer. */ - function broadcast(payload: MuxFrame): void { - const envelope = frame(payload) - for (const queue of muxQueues) queue.push(envelope) - } - - // Projection change feed → session/projection push frames. The carrier - // mints the wire frame (the Service Definition package holds no wire vocabulary); the - // child activates only when a projection registry is composed, and the - // subscription unwinds with this gateway's fiber. - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.onChanged((session, key, value, seq) => { - broadcast({ type: 'session/projection', sessionId: session.id, key, value, seq }) - }) - }) - - // The cache supplies recency and a monotonic non-blank hint. A cached - // `blank: true` remains only a prefix fact and is verified on the cold path. - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ - key: 'sessionListMetadata', - stateSchema: sessionListMetadataProjectionSchema, - init: () => ({ blank: true, lastPromptAt: null }), - apply: applySessionListMetadata, - wire: { viewSchema: sessionListMetadataProjectionSchema, view: state => state }, - stateVersion: 1, - }) - }) - - // The imageLimits projection unit: the attachments config this proxy - // enforces at prompt admission, constant per host boot. `apply` keeps the - // same state reference for every event, so no change frames are ever - // pushed — baselines alone carry the value — and clients pre-check intake - // and label upload affordances from it. Registered here, not in the - // attachment Service Definition: dsh-llm depends on dsh-attachment, so the - // seam package cannot reference the projection registry without a cycle, - // and the per-message rules the value describes are this proxy's own - // admission checks. The child activates only while both seams are composed. - // `view` reading the live service instead of the (null) state is sanctioned - // exactly for boot-constant units: the value cannot change within a process - // lifetime, so the fold stays observationally pure, and a stale persisted - // cache row re-viewing to the current config is the correct outcome. - ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'imageLimits', null>({ - key: 'imageLimits', - stateSchema: zod.null(), - init: () => null, - apply: state => state, - wire: { viewSchema: imageLimitsProjectionSchema, view: () => projectionCtx.attachments.imageLimits }, - stateVersion: 1, - }) - }) - - /** Project both durable inbox lists, optionally including the splice currently being emitted. */ - const queueItems = ( - agent: Agent, - splice?: SessionEventMap['agent/inbox/spliced'], - ): QueuedInboxItem[] => { - const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => { - const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep - return splice?.target === target - ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted) - : messages - } - return [ - ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })), - ...project('next-step').map(message => ({ - id: message.id, - // Only user-origin messages are steering; injected context (approval - // notices, task completion, attached snapshots) is not a user action - // and must not render as a pending steering bubble. - placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, - message, - })), - ] - } - - ctx.on('session/event', (session, event) => { - if (event.type !== 'agent/inbox/spliced') return - const agent = ctx.agents.get(session.id) - if (agent?.session !== session) return - broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) }) - }) - - /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */ - function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void { - pendingQuestions.delete(pending.rpcId) - if (pending.signal !== undefined && pending.onAbort !== undefined) { - pending.signal.removeEventListener('abort', pending.onAbort) - } - broadcast({ - type: 'question/resolved', sessionId: pending.sessionId, - questionRpcId: pending.rpcId, outcome, - }) - } - - const disposeProvider = ctx.userQuestions.registerProvider({ - ask(request: AskUserQuestionRequest): Promise { - const sessionId = request.agent?.id - if (sessionId === undefined) { - return Promise.reject(new UserQuestionError( - 'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT')) - } - return new Promise((resolve, reject) => { - const rpcId = RpcId(randomUUID()) - const pending: PendingQuestion = { - rpcId, sessionId, questions: request.questions, resolve, reject, - ...(request.signal === undefined ? {} : { signal: request.signal }), - } - const onAbort = (): void => { - claimQuestion(pending, 'cancelled') - reject(new UserQuestionError( - 'ask_user_question was aborted before the user answered', 'ASK_ABORTED')) - } - pending.onAbort = onAbort - pendingQuestions.set(rpcId, pending) - request.signal?.addEventListener('abort', onAbort, { once: true }) - const envelope: RpcRequest = { - rpcId, - payload: { type: 'question/requested', sessionId, questions: request.questions }, - } - for (const queue of muxQueues) queue.push(envelope) - }) - }, - }) - ctx.effect(() => () => { - disposeProvider() - for (const pending of [...pendingQuestions.values()]) { - claimQuestion(pending, 'cancelled') - pending.reject(new UserQuestionError( - 'web user-questions provider was disposed', 'ASK_ABORTED')) - } - }, 'api-proxy: user-questions provider') - - // --- Approval pending registry ------------------------------------------ - // The proxy is the approval channel for every agent this host owns: an ask - // through `ctx.approval` becomes an answerable server-request on the mux - // stream (stable rpcId), settled by POST /api/respond. The entry survives - // client disconnects — mux-open replays still-pending requested frames with - // the same rpcId (the refresh-recovery baseline) — and withdraws on the - // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers. - if (ctx.get('approval') !== undefined) { - // Teardown parity with the question provider above: a gateway disposed - // while approvals are pending settles every entry as 'cancelled' (the - // service's fail-closed vocabulary), so no ask promise dangles past the - // proxy's lifetime and subscribers see the withdrawal. - ctx.effect(() => () => { - for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled') - }, 'api-proxy: approval registry teardown') - ctx.on('approval/request', (req, next) => { - // Dispatch rides a microtask behind the service's own signal check: an - // abort landing in that window would register the abort listener AFTER - // the signal fired — never invoked, entry pending forever, zombie frame - // on every mux replay. Settle synchronously instead of publishing. - if (req.signal?.aborted === true) return Promise.resolve('cancelled') - // The audit pair `approval/asked` is already appended by the service - // before dispatch, but dispatch rides a microtask: parallel tool calls - // can append several asked events before any answerer runs. THIS - // request's event is therefore the newest asked event that is still - // undecided, unclaimed by another pending entry, and — when the ask - // names a call — carries the same callId. - const events = req.agent.session.events - const claimed = new Set() - for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId) - const decided = new Set() - let approvalId: ApprovalRequestId | undefined - for (let i = events.length - 1; i >= 0; i -= 1) { - const event = events[i] as SessionEvent - if (event.type === 'approval/decided') { - decided.add(event.data.id) - } else if (event.type === 'approval/asked') { - if (decided.has(event.data.id) || claimed.has(event.data.id)) continue - // Symmetric pairing: a callId-bearing ask only takes its own call's - // record, and a callId-less ask only takes a callId-less record — - // so neither shape can steal the other's audit id under parallel - // asks. (Every shipped producer — the tool executor — passes callId; - // the callId-less arm guards any future non-tool asker.) - if ((req.callId ?? null) !== (event.data.callId ?? null)) continue - approvalId = event.data.id - break - } - } - // No asked event means the request bypassed the service's audit path — - // not this channel's question; delegate to the fail-closed default. - if (approvalId === undefined) return next() - const id = approvalId - return new Promise((resolve) => { - const settle = (outcome: ApprovalOutcome): void => { - /* v8 ignore next 3 -- defensive double-settle guard: respond() routes - through the pending table (a settled id is not-pending before it can - re-settle) and the first settle removes the abort listener, so no - reachable path settles twice; kept against future settle callers. */ - if (!pendingApprovals.delete(pending.rpcId)) return - req.signal?.removeEventListener('abort', onAbort) - broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome }) - // A cancelled ask was already settled by the service's own signal - // race, which discards this late resolution; resolving is a no-op - // there and keeps this promise from dangling forever. - resolve(outcome) - } - const onAbort = (): void => { settle('cancelled') } - const pending: PendingApproval = { - rpcId: RpcId(randomUUID()), - sessionId: req.agent.session.id, - approvalId: id, - toolName: req.toolName, - ...req.callId === undefined ? {} : { callId: req.callId }, - ...req.reason === undefined ? {} : { reason: req.reason }, - resolve: settle, - } - pendingApprovals.set(pending.rpcId, pending) - req.signal?.addEventListener('abort', onAbort, { once: true }) - const envelope = requestedFrame(pending) - for (const queue of muxQueues) queue.push(envelope) - }) - }) - } - - type SessionReadState = { - id: SessionId - header: SessionHeader - events: SessionEvent[] - } - - /** Read one stable session prefix without acquiring an Agent owner. */ - async function readSessionState(sessionId: SessionId): Promise { - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined) { - return { - id: attached.id, - header: attached.header, - events: [...attached.events], - } - } - const inspected = await inspectServable(sessionId) - return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } - } - - /** Resolve the Workspace inherited by a fork without making ordinary loose lineage grouped. */ - async function forkWorkspace(source: Pick): Promise { - const workspaces = ctx.workspaceRegistry.list() - const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) - if (direct !== undefined || source.header.origin !== 'subagent') return direct - - const lineage = await ctx.sessionQuery.traceSession(source.id) - for (const ancestor of lineage.ancestors) { - const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id)) - if (workspace !== undefined) return workspace - } - return undefined - } - - /** - * Resolve which session one transcript read is served from, without - * acquiring an Agent owner. This is the read's only asynchronous step - * besides ensuring the composition; {@link historyCutOf} takes the cut. - * @param sessionId - the transcript being read. - * @returns the attached session, or the inspected detached header and events. - * @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity. - */ - async function historySourceFor(sessionId: SessionId): Promise { - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined) return { kind: 'attached', session: attached } - const inspected = await inspectServable(sessionId) - return { kind: 'detached', header: inspected.meta, events: inspected.events } - } - - /** - * The header and events {@link presenterScopeFor} reads to decide which - * composition a transcript ran under. - * @param source - the live or detached session this read is served from. - * @returns that session's creation header and its events. - */ - function sourceSession(source: HistorySource): PresetBearingSession { - if (source.kind === 'detached') return { header: source.header, events: source.events } - return { header: source.session.header, events: source.session.events } - } - - /** - * One transcript cut: the events and the projection baseline that describe - * the SAME log position. - * - * Synchronous, and the two reads sit next to each other, because an attached - * session keeps appending: an `await` between them would serve events cut at - * N beside a baseline folded to N+1, which is one response describing two - * moments. The caller does its awaiting before this call. - * @param source - the live or detached session this read is served from. - * @param includeProjections - whether the caller asked for the baseline (a tail page does). - * @returns the events and, when asked, the baseline for that same position. - */ - function historyCutOf( - source: HistorySource, - includeProjections: boolean, - ): { events: SessionEvent[]; projections?: SessionProjectionsBlock } { - if (source.kind === 'detached') { - const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined - return { events: source.events, ...projections === undefined ? {} : { projections } } - } - const events = [...source.session.events] - const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined - return { events, ...projections === undefined ? {} : { projections } } - } - - /** - * The registry view scope a transcript's presenters resolve in. - * - * A live agent is that scope itself (its chain passes through its preset's - * standing layer). A cold session resolves its preset from the LOG, and the - * preset's STANDING key serves without resuming anything — ensuring the - * mount composes plugins but starts no agent, session, or turn. No roster, - * no recorded preset, or a preset the roster no longer supplies all fall - * back to the global layer: the transcript still serves, with the generic - * cards a viewless entry renders. - * - * Reading the header alone would render a session that switched while blank - * through the composition it was CREATED with. Every tool only the newer - * preset registers resolves to no presenter there, and the transcript - * silently degrades to generic cards for exactly the calls its history is - * made of. - * @param sessionId - the transcript being read. - * @param session - that session's header and log (attached or inspected). - * @returns the scope to pass to presenter lookups, or undefined for global. - */ - async function presenterScopeFor( + /** Resolve a Session's live or standing preset scope without resuming it. */ + async function sessionScopeFor( sessionId: SessionId, session: PresetBearingSession, ): Promise { @@ -1543,188 +307,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const presets = ctx.get('agentPresets') if (presets === undefined) return undefined try { - // An unrecorded preset (a log from before the roster existed) renders - // through the DEFAULT preset's standing layer: that is the composition - // an unnamed session composes, and presenters are pure display, - // so the worst a mismatch produces is the generic card it had anyway. return await presets.standingKeyFor(resolveSessionPreset(session)) } catch { - // Swallows only the unknown/unusable-preset rejection from the roster: - // a deleted or broken preset must degrade this read, never fail it. + // An unknown or unusable recorded preset falls back to the global registry. return undefined } } - /** Resolve one requested identity to a live agent, creating or resuming it once. */ - async function ensureSession( - sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId?: string, - ): Promise { - let creation = sessionCreations.get(sessionId) - if (creation === undefined) { - creation = (async () => { - const attached = ctx.sessions.get(sessionId) - const live = ctx.agents.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, live)) { - throw new SubagentSessionOwnership(sessionId) - } - if (live !== undefined) return live - - const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined - const stored = persistence === undefined - ? undefined - : (await persistence.list()).find(header => header.id === sessionId) - if (persistence !== undefined && stored !== undefined) { - const inspected = await persistence.inspect(sessionId) - // Ownership first: explicit-id adoption of a session-backed - // subagent must answer `agent-busy` regardless of the requested - // cwd (the api/commands.ts contract), not a cwd conflict. - if (hasSubagentOwner({ header: inspected.meta }, undefined)) { - throw new SubagentSessionOwnership(sessionId) - } - if (inspected.meta.cwd !== cwd) { - throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) - } - // Resolved from the log, not the header: a session that switched - // while blank ran every turn under the newer composition. - const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events }) - assertPresetUnchanged(sessionId, presetId, storedPreset) - // The stored preset wins over anything the request names: a resumed - // session's history was produced under that composition, and - // rebuilding it differently would replay tool calls the model can no - // longer make. - return (await ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: agentOptions(), - setup: (await composeAgent(storedPreset)).setup, - })).agent - } - - try { - await mkdir(cwd, { recursive: true }) - } catch (error: unknown) { - throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) - } - const composition = await composeAgent(presetId) - return (await ctx.agents.create({ - sessionId, - agentOptions: agentOptions(), - meta: { - cwd, - ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }, - }, - setup: composition.setup, - })).agent - })().catch((error: unknown) => { - // Another Host entry path may have published the same identity while - // this operation crossed an asynchronous persistence/filesystem step. - const live = ctx.agents.get(sessionId) - if (live !== undefined) { - if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId) - return live - } - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, undefined)) { - throw new SubagentSessionOwnership(sessionId) - } - throw error - }).finally(() => { - sessionCreations.delete(sessionId) - }) - sessionCreations.set(sessionId, creation) - } - const agent = await creation - if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId) - // Beside the cwd check for the same reason, and after the await so it - // covers every path that yields a live agent — freshly created, adopted - // live, resumed from disk, or recovered by the concurrent-creation catch. - assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session)) - if (agent.session.header.cwd !== cwd) { - throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd) - } - return agent - } - - /** Resolve or create one path while holding the Host's workspace-create chain. */ - function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> { - const operation = workspaceCreationChain.then(async () => { - const existing = await ctx.workspaceRegistry.resolveByPath(path) - if (existing !== undefined) return { workspace: existing, created: false } - return { workspace: await ctx.workspaceRegistry.create(path), created: true } - }) - workspaceCreationChain = operation.then(() => undefined, () => undefined) - return operation - } - - /** - * Build the session.list baseline shared by listing and search visibility. - * Attached sessions come from memory; servable cold sessions merge from - * persistence, and the final order is newest-first. - */ - async function listVisibleSessionSummaries(signal?: AbortSignal): Promise { - signal?.throwIfAborted() - const summarizeAttached = (session: Session): SessionSummary => { - const agent = ctx.agents.get(session.id) - const projections = listProjectionsFor(ctx, session.header, session) - return { - ...summarize(session, agent?.status === 'running'), - ...projections === undefined ? {} : { projections }, - } - } - const items = ctx.sessions.list().map(summarizeAttached) - signal?.throwIfAborted() - const attached = new Set(items.map(item => item.sessionId)) - const persistence = ctx.get('sessionPersistence') - if (persistence !== undefined) { - const cold = (await persistence.list(signal)) - .filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) - signal?.throwIfAborted() - for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) { - signal?.throwIfAborted() - const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE) - const settled = await Promise.allSettled( - batch.map(async (meta) => { - // Projection hints remain optional. Blank verification may read - // this Session's artifact only when it passes the configured size check. - const projections = listProjectionsFor(ctx, meta, undefined) - const summary = await summarizeCold( - ctx, - persistence, - meta, - projections?.values.sessionListMetadata, - coldBlankProbeMaxBytes, - signal, - ) - const attachedSession = ctx.sessions.get(meta.id) - if (attachedSession !== undefined) return summarizeAttached(attachedSession) - return { - ...summary, - ...projections === undefined ? {} : { projections }, - } - }), - ) - const summaries: SessionSummary[] = [] - let rejected = false - let failure: unknown - for (const result of settled) { - if (result.status === 'fulfilled') { - summaries.push(result.value) - } else if (!rejected) { - rejected = true - failure = result.reason - } - } - if (rejected) throw failure - signal?.throwIfAborted() - items.push(...summaries) - } - } - items.sort((a, b) => b.updatedAt - a.updatedAt) - return items - } - /** * Resolve the goal service THIS agent runs. * @@ -1766,47 +355,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } - /** - * Whether an adapter currently serves this provider, and therefore whether - * a session selecting it can start a turn. Catalog membership cannot answer - * it: an adapter may serve a model its own catalog stopped advertising, so - * a provider missing from the groups is not the same as one nothing serves. - * A composition with no llm registry at all cannot judge and says yes — - * the dispatch it would have refused fails on its own terms. - */ - function routeServed(provider: string): boolean { - const llm = ctx.get('llm') - return llm === undefined || llm.listProviders().some(entry => entry.id === provider) - } - - /** - * Resolve the addressed agent for a turn-starting method and refuse when no - * adapter serves its current selection: a provider nothing serves cannot start a - * turn, and letting it try spends the whole pre-step path to fail inside - * the adapter with a message about registration. Refusing here names the - * model the session is pointed at while the draft is still in the composer. - * This is `session.prompt`'s enforcement boundary: a client that disables - * its input is an affordance, and the method stays callable regardless. - */ - async function turnAgentFor( - request: RpcRequest, sessionId: SessionId, - ): Promise<{ agent: Agent } | { refused: RpcResponse }> { - const found = await agentFor(sessionId) - if ('error' in found) return { refused: err(request, found.error) } - const agent = found.agent - const selection = selectionFor(agent).current - if (!routeServed(selection.provider)) { - return { - refused: err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${selection.provider}"; select a model for this session`, - details: { provider: selection.provider, model: selection.model }, - }), - } - } - return { agent } - } - /** Missing-service report shared by the settings domain (skills-domain stance). */ function settingsAbsent(): RpcError { return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', details: {} } @@ -1936,603 +484,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return { - sessions: { - // Attached sessions summarize from memory; persisted-but-unattached (cold) - // sessions merge in from the persistence store so history survives restarts. - // Logs without a cwd are not served; every session records its project - // at create time. - async list(request) { - return ok(request, { items: await listVisibleSessionSummaries() }) - }, - - async search(request, signal) { - const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, { - code: 'cancelled', - message: 'session search was aborted', - details: {}, - }) - if (isAborted(signal)) return cancelled() - const sessionQuery = ctx.get('sessionQuery') - if (sessionQuery === undefined) { - return err(request, { - code: 'internal', - message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query', - details: {}, - }) - } - try { - const visible = await listVisibleSessionSummaries(signal) - if (isAborted(signal)) return cancelled() - if (visible.length === 0) return ok(request, { items: [], hasMore: false }) - const visibleIds = new Set(visible.map(item => item.sessionId)) - const authorized: SessionSearchItem[] = [] - const acceptedIds = new Set() - const seenCursors = new Set() - let cursor: SessionSearchCursor | undefined - let providerCallCount = 0 - let providerPageLimit = SESSION_SEARCH_RESULT_LIMIT - while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) { - if (isAborted(signal)) return cancelled() - if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) { - throw new Error( - `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`, - ) - } - providerCallCount++ - const requestedCursor = cursor - const requestedPageLimit = providerPageLimit - let page - try { - page = await sessionQuery.searchSessions({ - query: request.payload.query, - eventFilters: [ - { kind: 'type', values: ['user/message', 'assistant/message'] }, - { kind: 'surface', values: ['current'] }, - ], - limit: requestedPageLimit, - ...requestedCursor === undefined ? {} : { cursor: requestedCursor }, - }, { signal }) - } catch (error: unknown) { - if (isAborted(signal)) return cancelled() - if ( - requestedCursor === undefined - && error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_INVALID_LIMIT' - && requestedPageLimit > 1 - ) { - providerPageLimit = Math.max(1, Math.floor(requestedPageLimit / 2)) - continue - } - if ( - requestedCursor !== undefined - && error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_STALE_CURSOR' - ) { - authorized.length = 0 - acceptedIds.clear() - seenCursors.clear() - cursor = undefined - continue - } - throw error - } - if (isAborted(signal)) return cancelled() - const providerItemCount = page.items.length - if (providerItemCount > requestedPageLimit) { - throw new Error( - `session search provider returned ${providerItemCount} items; maximum is ${requestedPageLimit}`, - ) - } - // Host visibility is the authorization boundary. Consume the - // provider's globally ranked results rather than binding every - // visible id into one SQLite statement, then require each hit to - // name a visible session and a current message from that same - // session before emitting its snippet. - for (const hit of page.items) { - if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue - if ( - !visibleIds.has(hit.header.id) - || hit.bestMatch.sessionId !== hit.header.id - || hit.bestMatch.surface !== 'current' - || !MESSAGE_TYPES.has(hit.bestMatch.type) - || acceptedIds.has(hit.header.id) - ) continue - const snippet = truncateUnicodeCodePoints( - hit.bestMatch.snippet, - SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, - ) - acceptedIds.add(hit.header.id) - authorized.push({ - sessionId: hit.header.id, - snippet, - }) - } - const nextCursor = page.nextCursor - if (nextCursor !== undefined) { - if (seenCursors.has(nextCursor)) { - throw new Error('session search provider repeated a continuation cursor') - } - seenCursors.add(nextCursor) - } - if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || nextCursor === undefined) break - cursor = nextCursor - } - return ok(request, { - items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT), - hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT, - }) - } catch (error: unknown) { - if ( - isAborted(signal) - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') - ) return cancelled() - // XXX: Redact provider details before exposing this gateway beyond - // its current single-user local deployment. - return err(request, { - code: 'internal', - message: `session search failed: ${String(error)}`, - details: {}, - }) - } - }, - - async create(request) { - const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId - let workspace: Workspace | undefined - if (request.payload.workspaceId !== undefined) { - workspace = ctx.workspaceRegistry.get(brandWorkspaceId(request.payload.workspaceId)) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${request.payload.workspaceId}" not found`, - details: { workspaceId: request.payload.workspaceId }, - }) - } - } - const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd - const requestedPreset = request.payload.agentPreset - try { - await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset) - } catch (error: unknown) { - if (error instanceof AgentPresetConflict) { - return err(request, { - code: 'agent-preset-conflict', - message: error.message, - details: { - sessionId: error.sessionId, - requestedPreset: error.requestedPreset, - ...error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }, - }, - }) - } - const refused = presetFailure(request, error) - if (refused !== undefined) return refused - if (error instanceof SessionCwdConflict) { - return err(request, { - code: 'session-conflict', - message: error.message, - details: { - sessionId: error.sessionId, - requestedCwd: error.requestedCwd, - ...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }, - }, - }) - } - if (error instanceof SubagentSessionOwnership) { - return err(request, subagentOwnershipError(error.sessionId)) - } - return err(request, { - code: 'internal', - message: `failed to create session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - if (workspace !== undefined) { - try { - await workspace.attachSession(sessionId) - } catch (error: unknown) { - return err(request, { - code: 'workspace-attach-failed', - message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, - details: { sessionId, workspaceId: workspace.id }, - }) - } - } - // Echo the composition the session RUNS so a client can label it - // without waiting for the next list refresh — the create is the commit - // point that knows it (a caller that named none gets the default). - // Resolved from the log for the same reason `sessionListFields()` is: - // this handler also adopts an already-live session, and one that - // switched while blank runs a preset its header no longer names, so - // echoing the header would contradict both the adoption this call just - // allowed and the row `session.list` serves for the same session. - const created = ctx.agents.get(sessionId) - const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session) - return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } }) - }, - - async history(request) { - const { sessionId, beforeSeq, maxMessages } = request.payload - try { - const source = await historySourceFor(sessionId) - // Both awaits happen BEFORE the cut. Ensuring the recorded - // composition's standing mount is what registers its projection - // units, so a first cold read would otherwise serve a baseline - // missing every preset-owned key; and an attached session keeps - // appending, so awaiting between the two reads would pair events cut - // at N with a baseline folded to N+1. - const scope = await presenterScopeFor(sessionId, sourceSession(source)) - const cut = historyCutOf(source, beforeSeq === undefined) - const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope) - return ok(request, { - events: page.events, - hasMore: page.hasMore, - ...cut.projections === undefined ? {} : { projections: cut.projections }, - }) - } catch (error: unknown) { - if (error instanceof SessionNotFound) { - return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } }) - } - return err(request, { - code: 'internal', - message: `history unavailable for session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - }, - - async models(request) { - const { sessionId } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const current = selectionFor(found.agent).current - const { groups, failures } = await buildModelCatalog(ctx) - const routable = routeServed(current.provider) - return ok(request, { current: { ...current }, routable, groups, failures }) - }, - - async selectModel(request) { - const { sessionId, provider, model, reasoningEffort } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - return serializeImageAdmission(found.agent, async () => { - try { - const resolved = await ctx.llm.resolveCallConfig({ - provider, - model, - ...reasoningEffort === undefined - ? {} - : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, - }) - const selected: ModelSelection = { - provider: resolved.provider, - model: resolved.model, - ...resolved.reasoningEffort === undefined - ? {} - : { reasoningEffort: resolved.reasoningEffort }, - } - selectionFor(found.agent).current = selected - try { - await defaults.saveDefaultModelSelection?.(selected) - } catch (error: unknown) { - ctx.logger.warn( - `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`, - ) - } - return ok(request, { selected: { ...selected } }) - } catch (error: unknown) { - return err(request, { - code: 'model-unavailable', - message: error instanceof Error ? error.message : String(error), - details: { provider, model }, - }) - } - }) - }, - - async rename(request) { - const { sessionId, title } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const titles = ctx.get('sessionTitle') - if (titles === undefined) { - return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} }) - } - try { - const accepted = titles.rename(found.agent.session, title) - return ok(request, { title: accepted.title, seq: accepted.eventSeq }) - } catch (error: unknown) { - // Only the input's fault maps to title-invalid (the message is - // product-user-visible in the rename dialog); liveness and disposal - // races are deployment trouble, not a bad title. - if (error instanceof SessionTitleInvalidError) { - return err(request, { - code: 'title-invalid', - message: error.message, - details: { sessionId }, - }) - } - return err(request, { - code: 'internal', - message: `failed to rename session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - }, - - async fork(request) { - const { sessionId, atSeq } = request.payload - let source: SessionReadState - try { - source = await readSessionState(sessionId) - } catch (error: unknown) { - if (error instanceof SessionNotFound) { - return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } }) - } - return err(request, { - code: 'internal', - message: `fork source unavailable for session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - const events = source.events - // An in-log anchor belongs to the turn containing it and must never - // clip backward to an earlier completed turn. Omitted and past-end - // anchors retain the last-completed-turn shortcut. - const lastSeq = events.at(-1)?.seq ?? -1 - const anchoredBoundary = atSeq === undefined - ? undefined - : events.find(e => e.type === 'turn/end' && e.seq >= atSeq) - const boundary = anchoredBoundary - ?? (atSeq === undefined || atSeq > lastSeq - ? events.findLast(e => e.type === 'turn/end') - : undefined) - if (boundary === undefined) { - return err(request, { - code: 'fork-unavailable', - message: atSeq !== undefined && atSeq <= lastSeq - ? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}` - : `session "${sessionId}" has no completed turn to fork from`, - details: { sessionId }, - }) - } - // Extend the cut through trailing out-of-band appends (session/title, - // injections) up to the next turn/start: they are standalone events, so - // the seed stays balanced, and the child inherits a title generated - // right after the boundary turn. - let cut = boundary.seq + 1 - while (cut < events.length && events[cut]?.type !== 'turn/start') cut++ - let workspace: Workspace | undefined - try { - workspace = await forkWorkspace(source) - } catch (error: unknown) { - return err(request, { - code: 'internal', - message: `failed to resolve fork workspace for session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - const childId = `session-${randomUUID()}` as SessionId - // The child inherits the parent's composition for the same reason a - // resumed session keeps its own: the seeded history was produced under - // those tools, and composing anything else would strand the tool calls - // it already carries. No model-facing row sits in the host plane, so - // composing nothing would leave the child with no tools at all. - const forkComposition = await composeAgent(resolveSessionPreset(source)) - try { - await ctx.agents.create({ - sessionId: childId, - seed: events.slice(0, cut), - meta: { - ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd }, - parentSession: source.id, - seedLength: cut, - ...forkComposition.agentPreset === undefined - ? {} - : { agentPreset: forkComposition.agentPreset }, - }, - agentOptions: agentOptions(), - setup: forkComposition.setup, - }) - } catch (error: unknown) { - return err(request, { - code: 'internal', - message: `failed to fork session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - // An ordinary source keeps its direct Workspace. A subagent source is - // not listed there, so its ordinary fork joins the nearest owning - // ancestor instead. The child is already published if attach fails. - if (workspace !== undefined) { - try { - await workspace.attachSession(childId) - } catch (error: unknown) { - return err(request, { - code: 'workspace-attach-failed', - message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`, - details: { sessionId: childId, workspaceId: workspace.id }, - }) - } - } - return ok(request, { sessionId: childId }) - }, - - async prompt(request) { - const { sessionId, mode, content, clientTimeZone } = request.payload - const canonicalTimeZone = clientTimeZone === undefined - ? undefined - : canonicalClientTimeZone(clientTimeZone) - if (clientTimeZone !== undefined && canonicalTimeZone === undefined) { - return err(request, { - code: 'invalid-time-zone', - message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', - details: { value: clientTimeZone }, - }) - } - const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) - if ('refused' in resolved) return resolved.refused - const agent = resolved.agent - // Request identity and optional browser zone ride the exact durable user message. - const source: MessageSource = { - kind: 'user', - rpcId: request.rpcId, - ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), - } - const hasImage = content.some(part => part.type === 'image') - const admit = async (): Promise> => { - try { - if (hasImage) { - const current = selectionFor(agent).current - const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model) - if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { - return err(request, { - code: 'attachment-error', - message: `Model "${current.model}" does not support image input.`, - details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, - }) - } - } - const durable = await durablePromptContent(ctx, content) - const message: UserMessage = createUserMessage({ content: durable, source }) - if (mode === 'steer') agent.steer(message) - else agent.followup(message) - } catch (error: unknown) { - if (error instanceof AttachmentError) { - return err(request, { - code: 'attachment-error', - message: error.message, - details: { reason: error.code }, - }) - } - return err(request, { - code: 'agent-busy', - message: 'prompt rejected', - details: { reason: String(error) }, - }) - } - return ok(request, { accepted: true as const }) - } - return hasImage ? serializeImageAdmission(agent, admit) : admit() - }, - - async attachment(request) { - const { sessionId, attachmentId } = request.payload - let state: SessionReadState - try { - state = await readSessionState(sessionId) - } catch (error: unknown) { - if (error instanceof SessionNotFound) { - return err(request, { - code: 'session-not-found', - message: error.message, - details: { sessionId }, - }) - } - return err(request, { - code: 'internal', - message: `attachment authorization unavailable for session "${sessionId}": ${String(error)}`, - details: {}, - }) - } - const ref = referencedImage(state.events, String(attachmentId)) - if (ref === undefined) { - return err(request, { - code: 'attachment-error', - message: 'Image is not referenced by this session.', - details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, - }) - } - try { - const stored = await ctx.attachments.readImage(ref) - return ok(request, { - attachment: stored.ref, - data: Buffer.from(stored.data).toString('base64'), - }) - } catch (error: unknown) { - if (error instanceof AttachmentError) { - return err(request, { - code: 'attachment-error', - message: error.message, - details: { reason: error.code }, - }) - } - return err(request, { - code: 'internal', - message: 'Unable to read image attachment.', - details: {}, - }) - } - }, - - updateQueue(request) { - const { sessionId, itemId, action } = request.payload - if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) { - return Promise.resolve(err(request, { - code: 'attachment-error', - message: 'queue edits accept text content only', - details: { reason: 'QUEUE_EDIT_NON_TEXT' }, - })) - } - const agent = ctx.agents.get(sessionId) - if (agent !== undefined && hasSubagentOwner(agent.session, agent)) { - return Promise.resolve(err(request, subagentOwnershipError(sessionId))) - } - if (agent === undefined) { - return Promise.resolve(err(request, { - code: 'queue-item-not-found', - message: 'queued item is no longer pending', - details: { itemId }, - })) - } - const target = agent.inbox.nextTurn.some(message => message.id === itemId) - ? 'next-turn' - : agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined - const message = target === undefined - ? undefined - : (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep) - .find(candidate => candidate.id === itemId) - if (target === undefined || message === undefined) { - return Promise.resolve(err(request, { - code: 'queue-item-not-found', - message: 'queued item is no longer pending', - details: { itemId }, - })) - } - if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) { - return Promise.resolve(err(request, { - code: 'steer-unavailable', - message: 'current turn no longer accepts steering', - details: { itemId }, - })) - } - if (action.kind === 'edit') { - agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content })) - } else { - agent.inbox.remove(itemId) - if (action.kind === 'steer') agent.steer(message) - } - return Promise.resolve(ok(request, { accepted: true as const })) - }, - - cancel(request) { - const { sessionId } = request.payload - const agent = ctx.agents.get(sessionId) - if (agent === undefined) { - return Promise.resolve(err(request, { - code: 'session-not-found', - message: `session "${sessionId}" not found (not attached)`, - details: { sessionId }, - })) - } - if (hasSubagentOwner(agent.session, agent)) { - return Promise.resolve(err(request, subagentOwnershipError(sessionId))) - } - agent.cancel({ kind: 'user' }, { keepInbox: true }) - return Promise.resolve(ok(request, { accepted: true as const })) - }, - }, - subagents: { async list(request, signal) { try { @@ -2565,75 +516,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - async history(request, signal) { - const { - parentSessionId, childSessionId, mode, beforeSeq, maxMessages, - } = request.payload - const verified = await catalogChild(ctx, { - parentSessionId, childSessionId, mode, - }, signal) - if (verified.error !== undefined) return err(request, verified.error) - // The generic-history data plane: an attached child serves its - // in-memory snapshot and the registry's live watermark projections; a - // cold child is one persistence inspection plus a detached fold. - let header: SessionHeader - let events: SessionEvent[] - let projections: SessionProjectionsBlock | undefined - const attached = ctx.sessions.get(childSessionId) - if (attached !== undefined) { - header = attached.header - events = [...attached.events] - projections = beforeSeq === undefined - ? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached)) - : undefined - } else { - try { - const inspected = await inspectServable(childSessionId) - header = inspected.meta - events = inspected.events - projections = beforeSeq === undefined - ? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events)) - : undefined - } catch (error: unknown) { - if (signal?.aborted) { - return err(request, { - code: 'cancelled', - message: 'subagent history read was cancelled', - details: {}, - }) - } - if (error instanceof SessionNotFound) { - return err(request, { - code: 'subagent-not-found', - message: 'subagent disappeared during history read', - details: { parentSessionId, childSessionId }, - }) - } - return err(request, { - code: 'internal', - message: 'subagent history read failed', - details: {}, - }) - } - } - if (signal?.aborted) { - return err(request, { - code: 'cancelled', - message: 'subagent history read was cancelled', - details: {}, - }) - } - if (header.parentSession !== parentSessionId) { - return err(request, { - code: 'subagent-unauthorized', - message: 'subagent parent changed during history read', - details: { childSessionId }, - }) - } - const page = historyPage(ctx, events, beforeSeq, maxMessages) - return ok(request, { ...page, ...projections === undefined ? {} : { projections } }) - }, - async prompt(request, signal) { const { parentSessionId, childSessionId, content, clientTimeZone } = request.payload const canonicalTimeZone = clientTimeZone === undefined @@ -2662,7 +544,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const messageId = await ctx.subagents.followup(parent, childSessionId, content, { source: { kind: 'user', - rpcId: request.rpcId, + rpcId: request.rpcId as unknown as SessionRequestId, ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), }, signal, @@ -2699,135 +581,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - workspace: { - list(request) { - return Promise.resolve(ok(request, { - items: ctx.workspaceRegistry.list().map(workspaceView), - archivedSessionIds: [...ctx.workspaceRegistry.archivedSessionIds], - })) - }, - - async create(request) { - const { path } = request.payload - try { - const { workspace, created } = await ensureWorkspace(path) - return ok(request, { workspace: workspaceView(workspace), created }) - } catch (error: unknown) { - // The registry rejects a path that does not resolve to an existing - // directory (realpath ENOENT / not-a-directory) — the business - // error of the typed-path flow, surfaced as a validation failure. - return err(request, { - code: 'workspace-invalid-path', - message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`, - details: { path }, - }) - } - }, - - async rename(request) { - const { payload } = request - const workspace = ctx.workspaceRegistry.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) - const title = payload.title.trim() - // Uniqueness AND the same-title no-op both ride the create chain so - // they observe the state left by earlier queued renames — checked - // up front, a queued A→A could report success while an earlier A→B - // still lands afterwards. - const operation = workspaceCreationChain.then(async () => { - if (title === workspace.title) return - if (ctx.workspaceRegistry.list().some(other => other.id !== workspace.id && other.title === title)) { - throw new WorkspaceNameConflictError(title) - } - await workspace.setTitle(title) - }) - workspaceCreationChain = operation.then(() => undefined, () => undefined) - try { - await operation - } catch (error: unknown) { - if (error instanceof WorkspaceNameConflictError) { - return err(request, { - code: 'workspace-name-conflict', - message: error.message, - details: { name: error.workspaceName }, - }) - } - throw error - } - return ok(request, { workspace: workspaceView(workspace) }) - }, - - async delete(request) { - const { workspaceId } = request.payload - const operation = workspaceCreationChain.then(() => - ctx.workspaceRegistry.delete(brandWorkspaceId(workspaceId))) - workspaceCreationChain = operation.then(() => undefined, () => undefined) - if (!await operation) return workspaceNotFound(request, workspaceId) - return ok(request, { deleted: true as const }) - }, - - async insertBefore(request) { - const { workspaceId, beforeWorkspaceId } = request.payload - try { - const workspaceIds = await ctx.workspaceRegistry.insertBefore( - brandWorkspaceId(workspaceId), - beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId), - ) - return ok(request, { workspaceIds: [...workspaceIds] }) - } catch (error: unknown) { - if (!(error instanceof WorkspaceOrderInvalidError)) throw error - return workspaceNotFound(request, error.workspaceId) - } - }, - - async insertSessionBefore(request) { - const { payload } = request - const workspace = ctx.workspaceRegistry.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) - try { - await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId) - } catch (error: unknown) { - // Only the entity's unaccounted-id rejection is the business code; - // storage/durability failures propagate as internal errors. - if (!(error instanceof WorkspaceMoveInvalidError)) throw error - return err(request, { - code: 'workspace-move-invalid', - message: error.message, - details: { - workspaceId: payload.workspaceId, - sessionId: payload.sessionId, - ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId }, - }, - }) - } - return ok(request, { workspace: workspaceView(workspace) }) - }, - - async archiveSession(request) { - const { sessionId } = request.payload - try { - await ctx.workspaceRegistry.archiveSession(sessionId) - } catch (error: unknown) { - // Only the registry's unknown-session rejection is the business - // code; storage/durability failures propagate as internal errors. - if (!(error instanceof WorkspaceUnknownSessionError)) throw error - return err(request, { - code: 'session-not-found', - message: error.message, - details: { sessionId }, - }) - } - return ok(request, { archivedSessionIds: [...ctx.workspaceRegistry.archivedSessionIds] }) - }, - }, - host: { describe(request) { // TODO(apiproxy-version): read the version from apps/cli/package.json. const selection = defaults.defaultModelSelection() return Promise.resolve(ok(request, { version: '0.0.1', - // Same source as session.create's fallback: the UI's default project - // must match where an unspecified-cwd session actually lands. + // This must match the default cwd supplied to Session Controller so + // the UI's project hint names where a cwd-less create request lands. cwd: defaults.cwd, // Read live for the same reason: this is what the NEXT session will // start from, so a saved default has to be what it reports. @@ -2881,8 +642,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // stops the backend's directory scan instead of outliving it. return ok(request, await capability.list(request.payload.path, signal)) } catch (error: unknown) { - // An abort is the caller's own timeout/disconnect, not a server - // failure — same code pickDirectory and command.execute report. + // An abort is the caller's own timeout/disconnect, not a server failure. if (signal.aborted) { return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} }) } @@ -3109,12 +869,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // the view scope is the live agent or the preset's standing key. async list(request) { const { sessionId } = request.payload - const session = ctx.sessions.get(sessionId) - if (session === undefined) { + let session: PresetBearingSession + try { + const inspected = await ctx.sessionController.inspect(sessionId) + session = { header: inspected.meta, events: inspected.events } + } catch (error: unknown) { + if (error instanceof ApiSessionNotFound) { + return err(request, { + code: 'session-not-found', + message: error.message, + details: { sessionId }, + }) + } return err(request, { - code: 'session-not-found', - message: `session "${sessionId}" not found (not attached)`, - details: { sessionId }, + code: 'internal', + message: `session "${sessionId}" could not be inspected: ${String(error)}`, + details: {}, }) } if (session.header.cwd === undefined) { @@ -3130,18 +900,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const live = ctx.agents.get(sessionId) const presets = ctx.get('agentPresets') const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') - // Same stance as the commands domain: a missing service means no - // composition mounts dsh-skill, not an empty catalog. `ctx.get` also + // A missing service means no composition mounts dsh-skill, not an + // empty catalog. `ctx.get` also // keeps this handler independent of the gateway plugin's inject list // (an undeclared `ctx.skills` property read fails the reflect proxy). const skillRegistry = scoped ?? ctx.get('skills') if (skillRegistry === undefined) { return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} }) } - // The scope presenters resolve in — the live agent, else the recorded - // preset's standing key, else the global layer — so a cold session's - // '/' popup lists the catalog its composition actually serves. - const scope = await presenterScopeFor(sessionId, session) + // Resolve the live or recorded preset scope so the catalog matches the + // Session composition without resuming its Agent. + const scope = await sessionScopeFor(sessionId, session) try { const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) return ok(request, { @@ -3324,216 +1093,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - events: { - mux(_request, signal) { - const queue = new FrameQueue>() - muxQueues.add(queue) - for (const session of ctx.sessions.list()) { - subscribeSession(queue, session) - } - for (const pending of pendingQuestions.values()) { - queue.push({ - rpcId: pending.rpcId, - payload: { - type: 'question/requested', sessionId: pending.sessionId, - questions: pending.questions, - }, - }) - } - // Refresh recovery: still-pending approval questions replay with their - // stable rpcId so a reconnecting client can still answer them. - for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending)) - // Queue snapshot baseline (pendingQuestions precedent): frames replayed - // in arrival order per session; a reconnecting client rebuilds its - // queue view from these alone. - for (const session of ctx.sessions.list()) { - const agent = ctx.agents.get(session.id) - if (agent?.session === session && agent.inbox.hasPending) { - queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) })) - } - } - // Background-task baseline. `ctx.agents.get` is the non-resuming read: - // a session with no live Agent owns no tasks, so it correctly sees only - // the unowned ones, and listing never revives a cold session. An empty - // set sends nothing — absence is how the client reads "no tasks". - const jobs = ctx.get('jobs') - if (jobs !== undefined) { - for (const session of ctx.sessions.list()) { - const views = jobViews(jobs.list(ctx.agents.get(session.id))) - if (views.length > 0) { - queue.push(frame({ type: 'session/jobs', sessionId: session.id, jobs: views })) - } - } - } - // Per-session open-call table for result-view pairing. Bounded by the - // per-turn call count: entries clear on turn/end; a table miss (stream - // opened mid-turn) backscans the session's in-memory events instead. - const openCalls = new Map>() - const disposers = [ - ctx.on('session/event', (session: Session, event: SessionEvent) => { - if (event.type === 'tool/call') { - const data = event.data as ToolCallData - try { - let table = openCalls.get(session.id) - if (table === undefined) openCalls.set(session.id, table = new Map()) - table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) }) - } catch { - // Unparseable model arguments: leave the table unset; the result view soft-falls. - } - } else if (event.type === 'turn/end') { - openCalls.delete(session.id) - } - const view = viewFor( - ctx, event, - callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId), - ctx.agents.get(session.id), - ) - queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } })) - }), - ctx.on('session/created', (session: Session) => { - subscribeSession(queue, session) - // The subscribe frame clears the client's task mirror, and a - // session born after the stream opened missed the baseline loop. - // Unowned tasks are visible to it from birth, so without this it - // would show none until the next registry change. - const views = jobs === undefined ? [] : jobViews(jobs.list(ctx.agents.get(session.id))) - if (views.length > 0) { - queue.push(frame({ type: 'session/jobs', sessionId: session.id, jobs: views })) - } - }), - ctx.on('session/disposed', (session: Session) => { - openCalls.delete(session.id) - }), - ...jobs === undefined ? [] : [jobs.onJobsChanged((owner) => { - if (owner !== undefined) { - // The exact owner instance the fence compares against, so the - // push stays correct even while that Agent's scope is tearing - // down and a lookup by id would already miss. - queue.push(frame({ type: 'session/jobs', sessionId: owner.id, jobs: jobViews(jobs.list(owner)) })) - return - } - // An unowned task is visible to every caller, so every subscribed - // session's set changed with it. - for (const session of ctx.sessions.list()) { - queue.push(frame({ - type: 'session/jobs', - sessionId: session.id, - jobs: jobViews(jobs.list(ctx.agents.get(session.id))), - })) - } - })], - ] - return queue.iterate(signal, () => { - muxQueues.delete(queue) - for (const dispose of disposers) dispose() - }) - }, - - host(_request, signal) { - const queue = new FrameQueue>() - const committedWorkspaces = ctx.workspaceRegistry.list() - const committedWorkspaceIds = new Set( - committedWorkspaces.map(workspace => String(workspace.id)), - ) - let committedWorkspaceOrder = committedWorkspaces.map(workspace => workspace.id) - // Frame-dedup baseline, same posture as committedWorkspaceIds: the - // stream opens against the current set; workspace.list re-baselines - // reconnecting clients, so only later changes need frames. - let archivedSessionIds = ctx.workspaceRegistry.archivedSessionIds - const disposers = [ - ctx.on('session/created', (session: Session) => { - queue.push(frame({ - type: 'host/session-added', - sessionId: session.id, - // Derived at frame time like summarize(); a just-created session - // has run no turn yet, so this is constantly true in practice. - blank: sessionBlank(session), - // Including cwd lets the client group the new session without refreshing the list. - ...sessionListFields(session.header, session.events), - })) - }), - ctx.on('session/disposed', (session: Session) => { - queue.push(frame({ type: 'host/session-removed', sessionId: session.id })) - }), - ctx.on('agent/status', ({ agent, status }: { agent: Agent; status: AgentStatus }) => { - queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' })) - }), - ctx.on('agent/error', ({ agent, error }: { agent: Agent; error: unknown }) => { - queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) })) - }), - ctx.on('domain/changed', (change) => { - if (change.domain !== 'workspace') return - if (change.table === '') { - if (change.operation !== 'put') return - const state = workspaceDomainState.parse(change.value) - const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length - && state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId))) - && state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index]) - for (const workspaceId of state.workspaceIds) { - if (committedWorkspaceIds.has(workspaceId)) continue - const workspace = ctx.workspaceRegistry.get(workspaceId) - if (workspace === undefined) { - throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`) - } - committedWorkspaceIds.add(workspaceId) - queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) - } - committedWorkspaceOrder = [...state.workspaceIds] - if (orderChanged) { - queue.push(frame({ - type: 'host/workspace-order-changed', - workspaceIds: [...state.workspaceIds], - })) - } - if (state.archivedSessionIds.length !== archivedSessionIds.length - || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { - archivedSessionIds = state.archivedSessionIds - queue.push(frame({ - type: 'host/archived-sessions-changed', - archivedSessionIds: [...state.archivedSessionIds], - })) - } - return - } - if (change.table !== 'workspaces') return - if (change.operation === 'deleted') { - if (!committedWorkspaceIds.delete(change.key)) return - queue.push(frame({ - type: 'host/workspace-removed', - workspaceId: change.key as WorkspaceId, - })) - return - } - if (!committedWorkspaceIds.has(change.key)) return - // Existing-entity table writes are complete attach/touch commits. - // A new entity's first put waits for the global registry write above. - queue.push(frame({ - type: 'host/workspace-changed', - workspace: changedWorkspaceView(change.key, change.value), - })) - }), - // Allowlisted host events ride one verbatim wrapper frame each. The - // allowlist is api-remotes', and `ctx.remote.$on` is the consumer - // face; nothing here projects, redacts, or renames. - ...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on( - name, - // The allowlist's shape assertion proves each name is a real, - // non-scoped, void-returning event, so the rest-parameter handler - // satisfies every member of the union `on` accepts here; - // assertJsonArgs proves the payload is JSON-safe before it queues. - ((...args: unknown[]) => { - queue.push(frame({ - type: 'host/remote-event', - event: name, - args: assertJsonArgs(name, args), - })) - }), - )), - ] - return queue.iterate(signal, () => { for (const dispose of disposers) dispose() }) - }, - }, - downloads: { async sessionLog(request, signal) { // Clean error path first: missing services answer 500 and a missing @@ -3590,53 +1149,5 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ) }, }, - - respond(message: ClientResponse): Promise { - // Route by the echoed rpcId (the wire correlation): approvals first, - // then questions — the two registries share one id space of UUIDs. - const approval = pendingApprovals.get(message.rpcId) - if (approval !== undefined) { - if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) - const parsed = approvalResponsePayloadSchema.safeParse(message.result.value) - // The payload's audit correlation must match the entry the rpcId routed - // to — a mismatched answer is malformed, not merely late. - if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - approval.resolve(parsed.data.outcome) - return Promise.resolve({ accepted: true }) - } - const pending = pendingQuestions.get(message.rpcId) - if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' }) - if (!message.result.ok) { - if (message.result.error.code !== 'cancelled') { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - claimQuestion(pending, 'cancelled') - pending.reject(new UserQuestionError( - 'the user cancelled ask_user_question', 'ASK_CANCELLED')) - return Promise.resolve({ accepted: true }) - } - const parsed = questionResponsePayloadSchema.safeParse(message.result.value) - if (!parsed.success) { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - const payload: QuestionResponsePayload = { - sessionId: parsed.data.sessionId, - answer: { - answers: parsed.data.answer.answers.map(answer => ({ - id: answer.id, - selected: answer.selected, - ...(answer.custom === undefined ? {} : { custom: answer.custom }), - })), - }, - } - if (!matchesQuestions(payload, pending)) { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - claimQuestion(pending, 'answered') - pending.resolve(payload.answer) - return Promise.resolve({ accepted: true }) - }, } } diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts index da3da6f9be..36a4a5b9c8 100644 --- a/packages/host/apiproxy/src/api/agent-presets.schema.ts +++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' +import { sessionIdSchema } from './ids.schema.ts' import type { AgentPresetEntry } from './agent-presets.ts' /** AgentPresetEntry row of agentPreset.list. */ diff --git a/packages/host/apiproxy/src/api/approvals.schema.ts b/packages/host/apiproxy/src/api/approvals.schema.ts deleted file mode 100644 index 2790d98a97..0000000000 --- a/packages/host/apiproxy/src/api/approvals.schema.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * approvals domain zod schemas (respond is a client-response; the payload schema serves - * the /api/respond endpoint's second parse after routing via the pending table). - * ApprovalRequestId brand cast point: one. - */ - -import { z } from 'zod' -import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types' -import type { ApprovalResponsePayload } from './approvals.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' - -/** ApprovalRequestId: one brand cast after schema validation (the only cast point in this domain). */ -export const approvalRequestIdSchema = z.string().min(1) as unknown as z.ZodType - -/** Approval answer payload (the result.value slot of a client-response). */ -export const approvalResponsePayloadSchema = z.object({ - sessionId: sessionIdSchema, - approvalId: approvalRequestIdSchema, - outcome: z.union([z.literal('allowed-once'), z.literal('rejected')]), -}) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/approvals.ts b/packages/host/apiproxy/src/api/approvals.ts deleted file mode 100644 index 780bc47431..0000000000 --- a/packages/host/apiproxy/src/api/approvals.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * approvals domain contract. The approval requested frame is a - * server-request (stable rpcId); the answer is a client-response echoing that rpcId (not a - * unary method, not in RpcMethodMap, mints no new id), carried on POST /api/respond with an - * RpcReceipt carrier receipt as the HTTP response body; the final outcome arrives in the resolved frame. - */ - -import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types' -import type { SessionId } from '@deepseek-ai/dsh-session/types' - -/** - * Approval answer payload (the result.value slot of a client-response). outcome accepts only - * the two values a client can give (cancelled/unavailable are host-side outcomes). approvalId - * is the core audit correlation (used by the impl to reconcile `approval/asked`/`decided`; - * passes through core's existing brand); wire correlation is governed by the echoed rpcId. - */ -export interface ApprovalResponsePayload { - sessionId: SessionId - approvalId: ApprovalRequestId - outcome: 'allowed-once' | 'rejected' -} diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts index 778225c0cc..cd95cfbd45 100644 --- a/packages/host/apiproxy/src/api/downloads.schema.ts +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -2,13 +2,12 @@ * downloads domain zod schemas. The download surface has no wire * envelope: the request arrives as query parameters (all strings), so its * request schema parses the raw query-parameter object into the method's - * exact request shape. SessionId brand cast point: sessionIdSchema, and only - * there (hosted in sessions.schema like every other cast). + * exact request shape. */ import { z } from 'zod' import type { DownloadsApi } from './downloads.ts' -import { sessionIdSchema } from './sessions.schema.ts' +import { sessionIdSchema } from './ids.schema.ts' /** * session.export query params → the sessionLog request. `includeDescendants` diff --git a/packages/host/apiproxy/src/api/downloads.ts b/packages/host/apiproxy/src/api/downloads.ts index d0e6138b6a..fc8728e497 100644 --- a/packages/host/apiproxy/src/api/downloads.ts +++ b/packages/host/apiproxy/src/api/downloads.ts @@ -1,7 +1,6 @@ /** - * downloads domain contract: host-only download surfaces — the GET-download - * channel family, the mirror of the SSE-stream `events` domain. No wire - * envelope: the carrier's GET routes answer these directly, and the browser + * downloads domain contract: Host-only GET download surfaces with no wire + * envelope. Carrier routes answer these directly, and the browser * `IApiClient` never exposes them. */ diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts deleted file mode 100644 index d4e66c8948..0000000000 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * events domain zod schemas: MuxFrame / HostFrame unions (discriminatedUnion('type')). - * A frame is the payload slot of the ServerRequest full form; the SessionEvent inside - * a session/event frame reuses sessions.schema's strict-envelope + wide-data passthrough branch. - */ - -import { z } from 'zod' -import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types' -import type { HostFrame, MuxFrame } from './events.ts' -import type { Wire } from './rpc.schema.ts' -import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' -import { approvalRequestIdSchema } from './approvals.schema.ts' -import { - contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema, -} from './sessions.schema.ts' -import { taskViewSchema } from './jobs.schema.ts' -import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' - -/** Question fields validated strictly against core dsh-user-questions. */ -export const askUserQuestionItemSchema = z.object({ - id: z.string(), - question: z.string(), - header: z.string().optional(), - detail: z.string().optional(), - options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(), - multiSelect: z.boolean().optional(), - // Presentation intent: a tagged union on the wire, so an unknown tag is a - // rejected frame rather than a silently generic render. - intent: z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('plan-review'), approve: z.string() }), - ]).optional(), -}) satisfies z.ZodType> - -/** Unified message envelope carried by transient queue frames. */ -const messageSchema = z.object({ - id: z.string().min(1), - role: z.union([z.literal('system'), z.literal('user'), z.literal('assistant')]), - content: z.array(contentBlockSchema), - source: z.looseObject({ kind: z.string() }), -}) - -/** MuxFrame union (payload slot of a mux-stream ServerRequest). */ -export const muxFrameSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }), - z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }), - z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }), - z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }), - // Non-empty by wire contract: the user-questions service rejects empty - // batches at ask() (EMPTY_QUESTIONS), so an empty frame is host breakage - // and must fail loud here, not reach the composer. - z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }), - z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }), - z.object({ - type: z.literal('session/queue'), - sessionId: sessionIdSchema, - items: z.array(z.object({ - id: messageIdSchema, - placement: z.union([z.literal('queued'), z.literal('steering'), z.literal('context')]), - message: messageSchema, - })), - }), - z.object({ type: z.literal('session/jobs'), sessionId: sessionIdSchema, jobs: z.array(taskViewSchema) }), - // value stays wide: it already passed its unit's own schema on the host, - // and deep-validating here would import every domain's schema into the carrier. - z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }), - z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), -]) as unknown as z.ZodType - -/** HostFrame union (payload slot of a host-stream ServerRequest). */ -export const hostFrameSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('host/session-added'), - sessionId: sessionIdSchema, - blank: z.boolean(), - parentSessionId: sessionIdSchema.optional(), - origin: z.literal('subagent').optional(), - cwd: z.string().optional(), - agentPreset: z.string().optional(), - }), - z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }), - z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }), - z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), - z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), - z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), - z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }), - z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), - // args stays wide, the same posture as session/projection's value: the frame - // arrives from JSON.parse, so every element is already a JSON value, and the - // structural contract belongs to the owner package's cordis `Events` - // declaration — the host validated JSON-safety before forwarding. - z.object({ type: z.literal('host/remote-event'), event: z.string().min(1), args: z.array(z.unknown()) }), - z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), -]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts deleted file mode 100644 index 0ff088745f..0000000000 --- a/packages/host/apiproxy/src/api/events.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * events domain contract: signatures and frame unions for the two logical - * streams. Four-quadrant: streams yield the narrow form `RpcRequest` (server-request - * view) — rpcId must be exposed to the business layer, because responses to answerable frames - * (approval/question requested) echo it; for pure pushes it identifies that one push. - * signal is a local stream-control parameter, independent of the request (never on the wire). - */ - -import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types' -import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types' -import type { Message } from '@deepseek-ai/dsh-llm/types' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { CallId } from '@deepseek-ai/dsh-llm/brand' -import type { JsonValue, SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' -import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' -import type { RpcError, RpcId, RpcRequest } from './rpc.ts' -import type { JobView } from './jobs.ts' -import type { WorkspaceView } from './workspace.ts' - -// Client-side consumers take the render-intent vocabulary from the contract; -// dsh-tools remains its owner. -export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' - -/** - * Host-computed render intent accompanying a `tool/call` or `tool/result` - * event. A pure derivation of args/result through the presenter registered at - * emission time — never persisted (the session log carries only the event), so - * the same event may carry a different view (or none) on a later delivery. - * `for` names which vocabulary applies without re-inspecting the event type. - * An absent view means the client's documented default (generic JSON card). - */ -export type ToolEventView = - | { for: 'call'; view: ToolCallView } - | { for: 'result'; view: ToolResultView } - -/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */ -export interface QueuedInboxItem { - /** Message identity used by inbox mutations. */ - id: MessageId - /** Agent-resolved FIFO placement; queued and steering items render on different surfaces, context items stay invisible until claimed. */ - placement: 'queued' | 'steering' | 'context' - /** Complete pending message; it is not durable until the Agent claims it. */ - message: Message -} - -/** Streaming face of the contract: the two logical stream openers (mux + host). */ -export interface EventsApi { - /** - * All-session aggregated mux stream. On open, emits a subscribed control frame for every - * attached session, then replays each session's still-pending approval/question requested - * frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the - * generic projection pair (history-tail projections block + session/projection frames). - * since: resume hook, unimplemented in v1 (ignored if passed); reconnection = reopen the - * stream + refetch history. - */ - mux(request: RpcRequest<{ since?: Record }>, signal: AbortSignal): AsyncIterable> - - /** - * Host-level info stream: session create/destroy, running-status flips, and - * agent failures with no turn position. Empty payload uses `{}`. - */ - host(request: RpcRequest<{}>, signal: AbortSignal): AsyncIterable> -} - -/** - * Mux stream frames: raw session-event passthrough + control frames + - * approval/question frames (requested = answerable server-request, the rest are pure pushes). - */ -export type MuxFrame = - | { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView } - | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number } - | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } - | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome } - | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } - | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' } - /** - * Complete transient inbox state after every enqueue, mutation, claim, or - * discard. Pending work is not model-visible and therefore has no durable - * session event; the whole snapshot makes edit, deletion, cancel, and - * reconnect converge through one authoritative signal. `session/queue` - * covers both resolved placements: queued items render - * in QueueDock, while pending steering renders at the conversation tail. - */ - | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } - /** - * Complete set of background jobs this session can see, after every registry - * commit that changes it: registration, the stopping transition, settlement, - * and owner-disposal removal. The registry is process-local and holds no - * durable event, so — exactly like `session/queue` — the whole snapshot is - * what makes a start, a kill, a reconnect, and a second tab converge on one - * authoritative value. - * - * Sent as a subscription baseline only for a session that currently has - * tasks; an absent key means an empty set. A change that empties the set - * still sends `[]`, since that transition is the only one absence cannot - * express. - */ - | { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] } - /** - * One projection unit's finished value changed (session-projection RFC). - * Live push state, never logged — replay recomputes on the host (the - * tool-view posture). `value` is the unit's schema-validated view output; - * `seq` is the unit's watermark at emission. Clients keep one generic - * per-session value store under higher-seq-wins, seeded by the history - * tail page's projections block. - */ - | { type: 'session/projection'; sessionId: SessionId; key: string; value: unknown; seq: number } - | { type: 'stream/error'; error: RpcError } - -/** - * Host stream frames. session-added carries the lineage anchor, product - * origin, project cwd, and blank bit (the list-summary fields a client cannot - * wait for a refresh to learn); the frame fires at session/created, so blank is - * constantly true — clients flip it on the session's first - * `host/session-status(running:true)` (a blank session never runs), and a - * reconnecting client takes `session.list`'s summary.blank as authoritative. - * agent-error is the only outlet for live failures with no turn position; - * workspace-changed pushes the full new snapshot after every durable - * workspace mutation (create/attach/order change — the client upserts, while - * `workspace.list` provides the reconnect baseline); workspace-removed is the - * committed registration-deletion increment and never implies directory or - * session-log deletion; workspace-order-changed pushes the complete durable - * registry order after a reorder; archived-sessions-changed pushes the full registry - * archive set after every durable change (same full-snapshot posture as - * workspace-changed — `workspace.list` re-baselines it on reconnect). - */ -export type HostFrame = - | { - type: 'host/session-added' - sessionId: SessionId - blank: boolean - parentSessionId?: SessionId - origin?: 'subagent' - cwd?: string - agentPreset?: string - } - | { type: 'host/session-removed'; sessionId: SessionId } - | { type: 'host/session-status'; sessionId: SessionId; running: boolean } - | { type: 'host/agent-error'; sessionId: SessionId; message: string } - | { type: 'host/workspace-changed'; workspace: WorkspaceView } - | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } - | { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] } - | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } - /** - * One allowlisted host cordis event forwarded verbatim. The allowlist is - * owned by `@deepseek-ai/dsh-api-remotes` (`API_REMOTE_FORWARDED_EVENTS`), - * which is also the only control point over what a consumer can receive. - * `event` is the host's own event name and `args` its argument list: this - * path applies no projection, no redaction, and no renaming, so the payload - * contract is the owner package's cordis `Events` declaration rather than - * anything stated here. Delivery lands on `ctx.remote.$on`, not on a - * per-event frame variant. - */ - | { type: 'host/remote-event'; event: string; args: JsonValue[] } - | { type: 'stream/error'; error: RpcError } diff --git a/packages/host/apiproxy/src/api/goals.ts b/packages/host/apiproxy/src/api/goals.ts index 9ed65e9461..abf6e53a07 100644 --- a/packages/host/apiproxy/src/api/goals.ts +++ b/packages/host/apiproxy/src/api/goals.ts @@ -2,11 +2,10 @@ * goals domain contract. Method signatures are the source of truth: * unary methods take the RpcRequest

narrow form and the impl echoes rpcId. * - * Mutations only: the read side is the 'goal' session projection (history - * tail-page projections block + session/projection frames), so there is no - * goal.get and no wire goal view — responses acknowledge with the new CAS - * ref and never feed client state (the committed goal/change event reaches - * every client through the mux stream carrying the same whole value). + * Mutations only: the read side is the `goal` Session projection carried by + * Session Controller history and control streams. There is no goal.get or + * separate wire goal view; responses acknowledge with the new CAS ref, and + * committed goal/change events update the projection. */ import type { Branded } from '@deepseek-ai/dsh-brand' diff --git a/packages/host/apiproxy/src/api/ids.schema.ts b/packages/host/apiproxy/src/api/ids.schema.ts new file mode 100644 index 0000000000..a79d7dc848 --- /dev/null +++ b/packages/host/apiproxy/src/api/ids.schema.ts @@ -0,0 +1,7 @@ +/** Branded identity schemas shared by the remaining API Proxy domains. */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { z } from 'zod' + +/** Non-empty Session identity after transport validation. */ +export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index b5e1d1ffd9..6fc92b71e4 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -1,81 +1,58 @@ /** * apiproxy contract-layer barrel. api/ has zero Node dependencies and is - * importable from the browser; the TS interfaces are the authoritative contract, while HTTP, - * WebSocket, and in-process SSE are merely physical channels (four-quadrant message model). + * importable from the browser; the TypeScript interfaces are authoritative, + * while HTTP supplies the carrier. */ -import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' -import type { WorkspaceApi } from './workspace.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SubagentsApi } from './subagents.ts' -import type { EventsApi } from './events.ts' import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { DownloadsApi } from './downloads.ts' -import type { ClientResponse, RpcReceipt } from './rpc.ts' /** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { - sessions: SessionsApi subagents: SubagentsApi host: HostApi - workspace: WorkspaceApi skills: SkillsApi agentPresets: AgentPresetsApi - events: EventsApi goals: GoalsApi settings: SettingsApi credentials: CredentialsApi llm: LlmApi /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ downloads: DownloadsApi - /** - * Response entry for server requests; not a domain method. - * @param message - Client response carrying the server request's rpcId. - * @returns Transport receipt for the response delivery. - */ - respond(message: ClientResponse): Promise } // ---- Domain interfaces and payload entities ---- export type { - HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, - SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, -} from './sessions.ts' + ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelReasoningEffort, ModelSelection, +} from '@deepseek-ai/dsh-api-session-controller/types' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry, SubagentPromptReceipt, SubagentsApi, } from './subagents.ts' -export type { JobView } from './jobs.ts' -export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' -export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' export type { DownloadsApi } from './downloads.ts' -export type { ApprovalResponsePayload } from './approvals.ts' - -export type { QuestionResponsePayload } from './questions.ts' // ---- Message layer: narrow forms (domain-signature view) ---- export type { RpcRequest, RpcResponse } from './rpc.ts' -// ---- Message layer: the four wire full forms + carrier receipt ---- +// ---- Message layer: unary wire forms ---- export type { ClientRequest, - ClientResponse, RpcMessage, - RpcReceipt, - ServerRequest, ServerResponse, } from './rpc.ts' @@ -84,15 +61,8 @@ export { RpcId, transportError } from './rpc.ts' export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' export { clientRequestSchema, - serverRequestSchema, serverResponseSchema, } from './rpc.schema.ts' -// ---- Fixed session-search product bounds ---- -export { - SESSION_SEARCH_RESULT_LIMIT, - SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, -} from './session-search.ts' - // ---- Method registry and derived generics ---- export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts' diff --git a/packages/host/apiproxy/src/api/jobs.schema.ts b/packages/host/apiproxy/src/api/jobs.schema.ts deleted file mode 100644 index 45f8b56786..0000000000 --- a/packages/host/apiproxy/src/api/jobs.schema.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * tasks domain zod schemas: the branded job id and the wire view carried by - * `session/jobs` frames. - */ - -import { z } from 'zod' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { JobView } from './jobs.ts' -import type { Wire } from './rpc.schema.ts' - -/** JobId: one brand cast after non-empty string validation. */ -export const taskIdSchema = z.string().min(1) as unknown as z.ZodType - -/** - * One wire task view. `kind` stays an open string because producer plugins - * extend the registry's kind map by declaration merging, so the closed set is - * not knowable at this boundary. - */ -export const taskViewSchema = z.object({ - id: taskIdSchema, - kind: z.string().min(1), - label: z.string().min(1), - status: z.union([ - z.literal('running'), - z.literal('stopping'), - z.literal('completed'), - z.literal('killed'), - z.literal('failed'), - ]), - detail: z.string().optional(), - startedAt: z.number().int().nonnegative(), - finishedAt: z.number().int().nonnegative().optional(), -}) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/jobs.ts b/packages/host/apiproxy/src/api/jobs.ts deleted file mode 100644 index 4351fc3bf1..0000000000 --- a/packages/host/apiproxy/src/api/jobs.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Browser-safe background-job domain contract. The registry's live records - * never cross the wire; a view is the subset a human list needs, minted fresh - * per push. - */ - -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' - -/** - * One background job as the client sees it. - * - * Three registry fields are deliberately absent. `ownerSession` is redundant - * beside the frame's own `sessionId`; `reported` is an internal notice-delivery - * bit with no user meaning; `outputLimitBytes` is producer-owned model - * presentation policy that never reaches a human surface. - */ -export interface JobView { - /** Registry-issued `-N` identity, stable for the task's whole life. */ - id: JobId - /** - * Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, …). Kept as a bare - * string because producer plugins extend the kind map by declaration merging, - * so no client build can enumerate the closed set. - */ - kind: string - /** Producer-supplied one-line label: the command, or the delegation description. */ - label: string - /** Current lifecycle state. */ - status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' - /** Kind-specific status detail ('exit code: 3'), present once the producer supplied one. */ - detail?: string - /** Epoch ms when the task was registered. */ - startedAt: number - /** Epoch ms when the task settled; absent while live. */ - finishedAt?: number -} diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index 7c8b0e6397..1fa7a6ceea 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -7,7 +7,48 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts' -import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts' +import type { + ModelCatalogFailure, + ModelCatalogModel, + ModelProviderGroup, + ModelReasoning, + ModelReasoningEffort, +} from '@deepseek-ai/dsh-api-session-controller/types' + +/** One adapter-owned reasoning effort. */ +const modelReasoningEffortSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), +}) satisfies z.ZodType> + +/** Exact-model reasoning metadata. */ +const modelReasoningSchema = z.object({ + efforts: z.array(modelReasoningEffortSchema).min(1), + defaultEffort: z.string().min(1).optional(), +}) satisfies z.ZodType> + +/** One advisory model entry inside a provider group. */ +const modelCatalogModelSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + reasoning: modelReasoningSchema.optional(), +}) satisfies z.ZodType> + +/** One successfully loaded provider group. */ +const modelProviderGroupSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + models: z.array(modelCatalogModelSchema), +}) satisfies z.ZodType> + +/** One provider-local catalog failure. */ +const modelCatalogFailureSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + message: z.string(), +}) satisfies z.ZodType> /** ConfigurableProviderView row of llm.providers. */ export const configurableProviderViewSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index e9a5b52e72..9aef65d856 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -2,14 +2,16 @@ * llm domain contract: host-scoped provider topology for configuration * surfaces. `llm.providers` merges the configurable-provider directory * (which providers CAN be configured, and where their settings live) with the - * live route registry; `llm.models` is the session-independent model catalog - * (the same groups as `session.models`, without a per-session selection). + * live route registry; `llm.models` is the session-independent model catalog. * Clients invalidate from the forwarded `llm/adapters-updated` and * `settings/document-updated` owner events. */ import type { RpcRequest, RpcResponse } from './rpc.ts' -import type { ModelCatalogFailure, ModelProviderGroup } from './sessions.ts' +import type { + ModelCatalogFailure, + ModelProviderGroup, +} from '@deepseek-ai/dsh-api-session-controller/types' /** Wire view of one configurable provider. */ export interface ConfigurableProviderView { diff --git a/packages/host/apiproxy/src/api/questions.schema.ts b/packages/host/apiproxy/src/api/questions.schema.ts deleted file mode 100644 index 772a646c32..0000000000 --- a/packages/host/apiproxy/src/api/questions.schema.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * questions domain zod schemas (respond is a client-response; the payload schema serves - * the /api/respond endpoint's second parse after routing via the pending table). The question - * identifier is the echoed rpcId; the payload carries no resource id. - */ - -import { z } from 'zod' -import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions/types' -import type { QuestionResponsePayload } from './questions.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' - -/** AskUserQuestionAnswer validated strictly against core dsh-user-questions. */ -export const askUserQuestionAnswerSchema = z.object({ - answers: z.array(z.object({ - id: z.string(), - selected: z.array(z.string()), - custom: z.string().optional(), - })), -}) satisfies z.ZodType> - -/** Question answer payload (the result.value slot of a client-response). */ -export const questionResponsePayloadSchema = z.object({ - sessionId: sessionIdSchema, - answer: askUserQuestionAnswerSchema, -}) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/questions.ts b/packages/host/apiproxy/src/api/questions.ts deleted file mode 100644 index 7a34b1ec65..0000000000 --- a/packages/host/apiproxy/src/api/questions.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * questions domain contract. The question requested frame is a - * server-request whose rpcId is the question's stable logical id (minted when the host accepts - * ask(); core user-questions has no request-level id); the answer is a client-response - * echoing that rpcId, with no resource id in the payload (rpcId suffices). - */ - -import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions/types' -import type { SessionId } from '@deepseek-ai/dsh-session/types' - -/** - * Question answer payload (the result.value slot of a client-response): - * answers one ask() as a whole batch (core: one ask, many questions, one - * answer — never split per question). - */ -export interface QuestionResponsePayload { - sessionId: SessionId - answer: AskUserQuestionAnswer -} diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 80dede1799..b24c5d9dfe 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -1,12 +1,9 @@ /** - * RPC method registry and signature-derived generics. The map - * registers only client-request methods (respond is a client-response, so it is absent); - * map keys are the wire path segments (POST /api/session.list). + * RPC method registry and signature-derived generics. Map keys are the wire + * path segments of API Proxy unary calls. */ -import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' -import type { WorkspaceApi } from './workspace.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.ts' @@ -19,23 +16,10 @@ import type { RpcResponse } from './rpc.ts' /** * Method name → method signature. Signatures are the single source of truth; payload/value * types are always derived from here. A method may declare a trailing AbortSignal after the - * request (command.execute): the carrier passes its request signal, never a wire field. + * request; the carrier passes its request signal, never a wire field. */ export interface RpcMethodMap { - 'session.list': SessionsApi['list'] - 'session.search': SessionsApi['search'] - 'session.create': SessionsApi['create'] - 'session.history': SessionsApi['history'] - 'session.models': SessionsApi['models'] - 'session.selectModel': SessionsApi['selectModel'] - 'session.rename': SessionsApi['rename'] - 'session.fork': SessionsApi['fork'] - 'session.prompt': SessionsApi['prompt'] - 'session.attachment': SessionsApi['attachment'] - 'session.updateQueue': SessionsApi['updateQueue'] - 'session.cancel': SessionsApi['cancel'] 'subagent.list': SubagentsApi['list'] - 'subagent.history': SubagentsApi['history'] 'subagent.prompt': SubagentsApi['prompt'] 'subagent.interrupt': SubagentsApi['interrupt'] 'host.describe': HostApi['describe'] @@ -43,13 +27,6 @@ export interface RpcMethodMap { 'host.listDirectory': HostApi['listDirectory'] 'host.createDirectory': HostApi['createDirectory'] 'host.openPath': HostApi['openPath'] - 'workspace.list': WorkspaceApi['list'] - 'workspace.create': WorkspaceApi['create'] - 'workspace.rename': WorkspaceApi['rename'] - 'workspace.delete': WorkspaceApi['delete'] - 'workspace.insertBefore': WorkspaceApi['insertBefore'] - 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] - 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'skill.list': SkillsApi['list'] 'agentPreset.list': AgentPresetsApi['list'] 'agentPreset.select': AgentPresetsApi['select'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 03cfdd1e15..9dacda7237 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -1,14 +1,14 @@ /** - * Message-layer zod schemas: the four wire full forms + error body + - * carrier receipt. The payload slot is unknown in the full-form schemas — business payloads - * get a second parse dispatched by method (two-level parse discipline). - * Brand cast point: rpcIdSchema, and only there. + * Message-layer zod schemas for API Proxy unary calls and Host pushes. The + * payload slot is unknown in the full-form schemas — business payloads get a + * second parse dispatched by method (two-level parse discipline). Brand cast + * point: rpcIdSchema, and only there. */ import { z } from 'zod' import type { z as zCore } from 'zod' type ZodIssue = zCore.core.$ZodIssue -import type { ClientRequest, ClientResponse, RpcError, RpcId, RpcReceipt, ServerRequest, ServerResponse } from './rpc.ts' +import type { ClientRequest, RpcError, RpcId, ServerResponse } from './rpc.ts' /** * Wire widening of a contract type: widens every property (deeply) to `original | undefined`. @@ -35,35 +35,20 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom()) }) }), z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), - z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }), - z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }), z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }), - z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }), - z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }), - z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), - z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), - z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), - z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), - z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), - z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), - z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), - z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }), z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }), z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({ @@ -89,7 +74,7 @@ export function rpcResultSchema(value: z.ZodType): z.ZodUnion -/** ServerRequest full form (payload stays wide). */ -export const serverRequestSchema = z.object({ - type: z.literal('server-request'), - rpcId: rpcIdSchema, - method: z.string(), - payload: z.unknown(), -}) as unknown as z.ZodType - -/** ClientResponse full form (result.value stays wide). */ -export const clientResponseSchema = z.object({ - type: z.literal('client-response'), - rpcId: rpcIdSchema, - result: rpcResultSchema(z.unknown().optional()), -}) as unknown as z.ZodType - /** Wire full-form union (discriminated by type). */ export const rpcMessageSchema = z.discriminatedUnion('type', [ clientRequestSchema as unknown as z.ZodObject, serverResponseSchema as unknown as z.ZodObject, - serverRequestSchema as unknown as z.ZodObject, - clientResponseSchema as unknown as z.ZodObject, ]) - -/** Carrier receipt schema. */ -export const rpcReceiptSchema = z.union([ - z.object({ accepted: z.literal(true) }), - z.object({ accepted: z.literal(false), reason: z.union([z.literal('not-pending'), z.literal('bad-response')]) }), -]) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 0b5506b6b6..fbbdbe63d0 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -1,14 +1,12 @@ /** - * Four-quadrant RPC message model. Channels and messages are decoupled: HTTP, - * WebSocket, and in-process SSE are physical carriers, while logical messages - * are channel-independent and form a four-member discriminated union. + * API Proxy request and response message model. Logical messages remain + * independent of their physical carrier. * api/ contract layer: zero Node dependencies, importable from the browser. */ import type { z as zCore } from 'zod' type ZodIssue = zCore.core.$ZodIssue import type { Branded } from '@deepseek-ai/dsh-brand' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' /** @@ -18,9 +16,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' export type RpcId = Branded<'rpc-id'> /** - * Brands a string as RpcId (same precedent as core `SessionId()`). Minted by the initiator: - * client-request → client mints; server-request → host mints (answerable frames get a stable - * logical id, pure pushes mint a fresh one each time). + * Brands a string as RpcId (same precedent as core `SessionId()`). The Client + * mints each request id and the Host echoes it in the response. * @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures). * @returns The same string, branded (compile-time cast, zero runtime cost). */ @@ -33,31 +30,16 @@ export interface RpcErrorDetailsMap { 'bad-request': { issues: ZodIssue[] } 'cancelled': {} 'session-not-found': { sessionId: SessionId } - 'model-unavailable': { provider: string; model: string } - 'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string } 'invalid-time-zone': { value: string } - 'workspace-attach-failed': { sessionId: SessionId; workspaceId: string } - 'workspace-not-found': { workspaceId: string } - 'workspace-invalid-path': { path: string } - 'workspace-name-conflict': { name: string } - 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } 'directory-unreadable': { path: string } 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } 'agent-preset-read-only': { agentPreset: string; reason: string } 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } - 'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string } - 'agent-preset-not-found': { agentPreset: string; available: string[] } + 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } - 'attachment-error': { reason: string } - 'queue-item-not-found': { itemId: MessageId } - 'steer-unavailable': { itemId: MessageId } - /** A known slash command reported a usage/state error; the message is the command's own text. */ - 'command-error': {} - /** A leading-/ prompt named no registered command; the message names the token. */ - 'unknown-command': {} /** * A settings write was refused (schema validation, unknown namespace, * read-only provider, or storage failure); the message is the seam's text. @@ -80,8 +62,6 @@ export interface RpcErrorDetailsMap { * details name the endpoint asked, never the credential offered. */ 'model-discovery-failed': { settingsNs: string; baseURL?: string } - 'title-invalid': { sessionId: SessionId } - 'fork-unavailable': { sessionId: SessionId } 'subagent-parent-unavailable': { parentSessionId: SessionId } 'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId } 'subagent-catalog-diagnostic': { @@ -139,7 +119,7 @@ export interface RpcResponse { result: RpcResult } -// ---- Wire full forms: four named members of a discriminated union (discriminant = the four `type` literals) ---- +// ---- Wire full forms ---- /** Call initiated by the client (wire carrier: POST /api/ body). */ export interface ClientRequest { @@ -156,32 +136,5 @@ export interface ServerResponse { result: RpcResult } -/** - * Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions - * (approval/question requested — stable rpcId, reused on replay) and pure pushes - * (session/event etc. — rpcId identifies that one push) share this shape; whether a - * response is expected is determined statically by method (a strict dichotomy, no third kind). - */ -export interface ServerRequest { - type: 'server-request' - rpcId: RpcId - method: string - payload: unknown -} - -/** Response to a ServerRequest (wire carrier: POST /api/respond body); rpcId echoed, never minted anew. */ -export interface ClientResponse { - type: 'client-response' - rpcId: RpcId - result: RpcResult -} - /** Authoritative wire full-form union; narrow via `switch (message.type)`. */ -export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse - -/** - * Carrier receipt (not an RpcMessage — it belongs to the carrier layer, same - * discipline as "HTTP status describes only the carrier"): the HTTP response - * body of the POST carrying a client-response. Late/duplicate responses yield not-pending. - */ -export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' } +export type RpcMessage = ClientRequest | ServerResponse diff --git a/packages/host/apiproxy/src/api/session-search.ts b/packages/host/apiproxy/src/api/session-search.ts deleted file mode 100644 index db68b3efde..0000000000 --- a/packages/host/apiproxy/src/api/session-search.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** Maximum number of sessions returned by one sidebar search. */ -export const SESSION_SEARCH_RESULT_LIMIT = 20 - -/** Maximum snippet length in Unicode code points. */ -export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 - -/** - * Return the longest prefix containing at most `maximum` Unicode code points. - * @param value - text to bound. - * @param maximum - non-negative code-point limit. - * @returns `value` unchanged when it fits, otherwise a code-point-safe prefix. - */ -export function truncateUnicodeCodePoints(value: string, maximum: number): string { - let count = 0 - let end = 0 - for (const codePoint of value) { - if (count === maximum) return value.slice(0, end) - count++ - end += codePoint.length - } - return value -} diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts deleted file mode 100644 index c415015776..0000000000 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ /dev/null @@ -1,354 +0,0 @@ -/** - * sessions domain zod schemas (names derived from map keys: sessionListRequestSchema / - * sessionListValueSchema). SessionEvent passthrough = strict envelope (type/seq/time) + wide - * data: the merge-extensible event API keeps an unknown-type branch at the union level, - * with no field-level passthrough. SessionId brand cast point: sessionIdSchema, and only there. - */ - -import { z } from 'zod' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import type { - HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionSummary, -} from './sessions.ts' -import type { ToolEventView } from './events.ts' -import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { WorkspaceId } from './workspace.ts' -import { - SESSION_SEARCH_RESULT_LIMIT, - SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, - truncateUnicodeCodePoints, -} from './session-search.ts' - -/** SessionId: one brand cast after schema validation (the only cast point in this domain). */ -export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType - -/** MessageId: one brand cast after non-empty string validation. */ -export const messageIdSchema = z.string().min(1) as unknown as z.ZodType - -/** - * WorkspaceId: the workspace domain's one brand cast. Hosted here rather - * than in workspace.schema because session.create references it while - * workspace.schema references sessionIdSchema — schema modules must stay a - * DAG (both casts used at module top level; a cycle is a load-time TDZ). - */ -export const workspaceIdSchema = z.string().min(1) as unknown as z.ZodType - -/** SessionEvent passthrough: strict envelope, wide data (the client fold handles unknown types via its documented default). */ -export const sessionEventSchema = z.object({ - type: z.string(), - seq: z.number().int().nonnegative(), - time: z.number(), - data: z.unknown(), - sourceEventSeqs: z.array(z.number()).optional(), - surfaceOp: z.unknown().optional(), - ignorable: z.literal(true).optional(), -}) as unknown as z.ZodType - -/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ -export const sessionSummarySchema = z.object({ - sessionId: sessionIdSchema, - updatedAt: z.number(), - running: z.boolean(), - blank: z.boolean(), - parentSessionId: sessionIdSchema.optional(), - origin: z.literal('subagent').optional(), - cwd: z.string().optional(), - agentPreset: z.string().optional(), - projections: z.lazy(() => sessionProjectionsBlockSchema).optional(), -}) as unknown as z.ZodType> - -/** session.list request payload (cursor is a reserved seat, unimplemented in v1). */ -export const sessionListRequestSchema = z.object({ - cursor: z.string().optional(), -}) satisfies z.ZodType>> - -/** session.list response value. */ -export const sessionListValueSchema: z.ZodType>> = z.object({ - items: z.array(sessionSummarySchema), -}) - -/** Fixed wire bound for one interactive sidebar query. */ -const SESSION_SEARCH_QUERY_MAX_CHARS = 500 - -/** session.search request payload. */ -export const sessionSearchRequestSchema = z.object({ - query: z.string().trim().min(1).max(SESSION_SEARCH_QUERY_MAX_CHARS) - .refine(query => !query.includes('\0'), { message: 'search query must not contain NUL' }), -}) satisfies z.ZodType>> - -/** One session.search result. */ -export const sessionSearchItemSchema = z.object({ - sessionId: sessionIdSchema, - snippet: z.string().refine( - snippet => truncateUnicodeCodePoints( - snippet, - SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, - ) === snippet, - { message: `search snippet must contain at most ${SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS} Unicode code points` }, - ), -}) satisfies z.ZodType> - -/** session.search response value. */ -export const sessionSearchValueSchema = z.object({ - items: z.array(sessionSearchItemSchema).max(SESSION_SEARCH_RESULT_LIMIT), - hasMore: z.boolean(), -}) satisfies z.ZodType>> - -/** session.create request payload (at most one of workspaceId / cwd). */ -export const sessionCreateRequestSchema = z.object({ - workspaceId: workspaceIdSchema.optional(), - cwd: z.string().optional(), - sessionId: sessionIdSchema.optional(), - agentPreset: z.string().optional(), -}).refine( - payload => payload.workspaceId === undefined || payload.cwd === undefined, - { message: 'session.create accepts workspaceId or cwd, not both' }, -) satisfies z.ZodType>> - -/** session.create response value. */ -export const sessionCreateValueSchema = z.object({ - sessionId: sessionIdSchema, - agentPreset: z.string().optional(), -}) satisfies z.ZodType>> - -/** session.rename request payload (raw title; host-side normalization decides acceptance). */ -export const sessionRenameRequestSchema = z.object({ - sessionId: sessionIdSchema, - title: z.string(), -}) satisfies z.ZodType>> - -/** session.rename response value (the normalized accepted title and its event seq). */ -export const sessionRenameValueSchema = z.object({ - title: z.string().min(1), - seq: z.number().int().nonnegative(), -}) satisfies z.ZodType>> - -/** session.fork request payload (atSeq anchors the completed-turn cut). */ -export const sessionForkRequestSchema = z.object({ - sessionId: sessionIdSchema, - atSeq: z.number().int().nonnegative().optional(), -}) satisfies z.ZodType>> - -/** session.fork response value (the child session id). */ -export const sessionForkValueSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */ -export const sessionHistoryRequestSchema = z.object({ - sessionId: sessionIdSchema, - beforeSeq: z.number().int().nonnegative().optional(), - maxMessages: z.number().int().positive().optional(), -}) satisfies z.ZodType>> - -/** Complete provider/model selection. */ -export const modelSelectionSchema = z.object({ - provider: z.string().min(1), - model: z.string().min(1), - reasoningEffort: z.string().min(1).optional(), -}) satisfies z.ZodType> - -/** One adapter-owned reasoning effort. */ -export const modelReasoningEffortSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), -}) satisfies z.ZodType> - -/** Exact-model reasoning metadata. */ -export const modelReasoningSchema = z.object({ - efforts: z.array(modelReasoningEffortSchema).min(1), - defaultEffort: z.string().min(1).optional(), -}) satisfies z.ZodType> - -/** One advisory model entry inside a provider group. */ -export const modelCatalogModelSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - reasoning: modelReasoningSchema.optional(), -}) satisfies z.ZodType> - -/** One successfully loaded provider group. */ -export const modelProviderGroupSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - models: z.array(modelCatalogModelSchema), -}) satisfies z.ZodType> - -/** One provider-local catalog failure. */ -export const modelCatalogFailureSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - message: z.string(), -}) satisfies z.ZodType> - -/** - * ToolEventView passthrough: lock only the `for` discriminant and the presence - * of a card-tagged `view` object. The view interior is a host-computed product - * the client reads without echoing back; deep-validating it would hand-copy - * the dsh-tools vocabulary into this schema and drift with it. - */ -export const toolEventViewSchema = z.discriminatedUnion('for', [ - z.object({ for: z.literal('call'), view: z.looseObject({ card: z.string() }) }), - z.object({ for: z.literal('result'), view: z.looseObject({ card: z.string() }) }), -]) as unknown as z.ZodType - -/** One session.history item: the session event plus its optional host-computed tool view. */ -export const historyEntrySchema: z.ZodType> = z.object({ - event: sessionEventSchema, - view: toolEventViewSchema.optional(), -}) as unknown as z.ZodType> - -/** - * Projection baseline passthrough: `values` stays a wide record — each value - * was already parsed by its provider's own schema on the host side, and - * deep-validating here would import every domain's schema into the carrier. - */ -export const sessionProjectionsBlockSchema = z.object({ - // -1 = empty log (the lastSeq convention of session/subscribed). - asOfSeq: z.number().int().min(-1), - values: z.record(z.string(), z.unknown()), -}) as unknown as z.ZodType> - -/** Host-side validation for the persisted Session-list projection. */ -export const sessionListMetadataProjectionSchema: z.ZodType = z.object({ - blank: z.boolean(), - lastPromptAt: z.number().nullable(), -}) - -/** - * imageLimits projection unit schema (host-side view validation). zod widens - * `readonly ImageMediaType[]` to `string[]`; on the JSON wire the two - * serialize identically, so the cast records exactly that widening. - */ -export const imageLimitsProjectionSchema = z.object({ - maxImageBytes: z.number().int().positive(), - maxImagesPerMessage: z.number().int().positive(), - maxMessageImageBytes: z.number().int().positive(), - maxImagePixels: z.number().int().positive(), - maxImageDimension: z.number().int().positive(), - mediaTypes: z.array(z.string()), -}) as unknown as z.ZodType - -/** session.history response value (projections rides the tail page only). */ -export const sessionHistoryValueSchema: z.ZodType>> = z.object({ - events: z.array(historyEntrySchema), - hasMore: z.boolean(), - projections: sessionProjectionsBlockSchema.optional(), -}) - -/** session.models request payload. */ -export const sessionModelsRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** session.models response value. */ -export const sessionModelsValueSchema = z.object({ - current: modelSelectionSchema, - routable: z.boolean(), - groups: z.array(modelProviderGroupSchema), - failures: z.array(modelCatalogFailureSchema), -}) satisfies z.ZodType>> - -/** session.selectModel request payload. */ -export const sessionSelectModelRequestSchema = z.object({ - sessionId: sessionIdSchema, - provider: z.string().min(1), - model: z.string().min(1), - reasoningEffort: z.string().min(1).optional(), -}) satisfies z.ZodType>> - -/** session.selectModel response value. */ -export const sessionSelectModelValueSchema = z.object({ - selected: modelSelectionSchema, -}) satisfies z.ZodType>> - -/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */ -export const contentBlockSchema = z.looseObject({ type: z.string() }) - -/** Raster image media types accepted by the version-one browser wire. */ -export const imageMediaTypeSchema = z.union([ - z.literal('image/png'), - z.literal('image/jpeg'), - z.literal('image/webp'), - z.literal('image/gif'), -]) - -/** Prompt wire content is intentionally narrower than merge-extensible durable core content. */ -export const promptContentPartSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('text'), text: z.string() }), - z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }), -]) - -/** session.prompt request payload, including optional browser-local request provenance. */ -export const sessionPromptRequestSchema = z.object({ - sessionId: sessionIdSchema, - mode: z.union([z.literal('queue'), z.literal('steer')]), - content: z.array(promptContentPartSchema), - clientTimeZone: z.string().optional(), -}) as unknown as z.ZodType> - -/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */ -export const sessionPromptValueSchema = z.object({ - accepted: z.literal(true), - command: z.object({ - kind: z.literal('success'), - text: z.string().optional(), - }).optional(), -}) satisfies z.ZodType>> - -/** Opaque attachment id after string-shape validation. */ -export const attachmentIdSchema = z.string().min(1) as unknown as z.ZodType - -/** Durable image reference returned from the authenticated session lookup. */ -export const imageAttachmentRefSchema = z.object({ - attachmentId: attachmentIdSchema, - mediaType: imageMediaTypeSchema, - bytes: z.number().int().positive(), - width: z.number().int().positive(), - height: z.number().int().positive(), - name: z.string().optional(), -}) as unknown as z.ZodType - -/** session.attachment request payload. */ -export const sessionAttachmentRequestSchema = z.object({ - sessionId: sessionIdSchema, - attachmentId: attachmentIdSchema, -}) satisfies z.ZodType>> - -/** session.attachment response value. */ -export const sessionAttachmentValueSchema = z.object({ - attachment: imageAttachmentRefSchema, - data: z.string(), -}) satisfies z.ZodType>> - -/** session.updateQueue request payload. */ -export const sessionUpdateQueueRequestSchema = z.object({ - sessionId: sessionIdSchema, - itemId: messageIdSchema, - action: z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }), - z.object({ kind: z.literal('remove') }), - z.object({ kind: z.literal('steer') }), - ]), -}) as unknown as z.ZodType> - -/** session.updateQueue response value. */ -export const sessionUpdateQueueValueSchema = z.object({ - accepted: z.literal(true), -}) satisfies z.ZodType>> - -/** session.cancel request payload. */ -export const sessionCancelRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** session.cancel response value. */ -export const sessionCancelValueSchema = z.object({ - accepted: z.literal(true), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts deleted file mode 100644 index 6b5fadf248..0000000000 --- a/packages/host/apiproxy/src/api/sessions.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** - * sessions domain contract. Method signatures are the source of truth: - * unary methods take the RpcRequest

narrow form and the impl echoes rpcId; everything - * else references RequestPayload<'session.*'> / ResponseValue<'session.*'>. - */ - -import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' -// The pure-type outlet: api/ is browser-importable, and the package root's -// cordis Context merge (via dsh-agent) must not enter client aggregates. -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts' -import type { ToolEventView } from './events.ts' -import type { WorkspaceId } from './workspace.ts' - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - sessionListMetadata: SessionListMetadata - imageLimits: null - } - interface SessionProjectionMap { - /** - * Session-list hints persisted by the projection cache. `blank: false` - * is monotonic and may suppress a cold-log probe; `blank: true` is only a - * checkpoint-prefix fact and must not hide a cold Session without direct - * verification. `lastPromptAt` is the latest human-authored prompt time. - */ - sessionListMetadata: SessionListMetadata - /** - * The deployment's image-intake limits: the attachments service's config - * as this proxy enforces it at prompt admission, constant per host boot. - * Clients pre-check count and bytes at intake and show the limits in - * upload affordances. Key absence means no attachment service is - * composed — clients skip the pre-check and let the host answer. - */ - imageLimits: ImageAttachmentLimits - } -} - -/** Persisted hints used to summarize a cold Session without reading a large log. */ -export interface SessionListMetadata { - /** Whether the checkpoint prefix contains no turn/start event. */ - blank: boolean - /** Latest source.kind=user message time in the checkpoint prefix. */ - lastPromptAt: number | null -} - -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - /** - * The prompt's rpcId is passed through MessageSource into the `user/message` event - * (the client uses it to reconcile the optimistically - * echoed provisional message with the event stream). kind stays `'user'` — the model face - * carries no transport vocabulary; rpcId and the optional Host-validated browser zone are - * durable JSON fields passed back to the client with the event. - */ - 'user-rpc': { kind: 'user'; rpcId: RpcId; clientTimeZone?: string } - } -} - -/** - * One history page entry: the raw event plus the optional host-computed render - * intent (same semantics as the mux frame's `view` slot — a pagination-time - * derivation, never persisted). - */ -export interface HistoryEntry { - event: SessionEvent - view?: ToolEventView -} - -/** - * The projection baseline riding the history tail page: one synchronous cut - * over every registered projection unit, read from the registry's watermark - * cache. `asOfSeq` is the seq of the last committed event every value - * reflects — the window tail event seq (`-1` for an empty log, mirroring - * `session/subscribed.lastSeq`), directly comparable with - * `session/projection` frame seqs under the client's higher-seq-wins rule. A - * key absent from `values` means the capability is absent (its domain plugin - * is unmounted). - */ -export interface SessionProjectionsBlock { - /** Seq of the last event the values reflect; -1 for an empty log. */ - asOfSeq: number - /** Whole current value per registered projection key. */ - values: Partial -} - -/** Browser-submitted prompt content; the host promotes image bytes to durable references. */ -export type PromptContentPart = - | { type: 'text'; text: string } - | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string } - -/** Complete model selection for one session. */ -export interface ModelSelection { - /** Registered provider route. */ - provider: string - /** Provider-owned model id. */ - model: string - /** Adapter-owned reasoning effort; absence preserves adapter/provider default behavior. */ - reasoningEffort?: string -} - -/** One adapter-owned reasoning effort displayed for an exact model route. */ -export interface ModelReasoningEffort { - /** Opaque value submitted back to the owning adapter. */ - id: string - /** Adapter-supplied display name. */ - name: string - /** Optional adapter-supplied description. */ - description?: string -} - -/** Selectable reasoning metadata for one exact model route. */ -export interface ModelReasoning { - /** Efforts in adapter-preferred display order. */ - efforts: ModelReasoningEffort[] - /** Adapter-configured default; absence preserves the provider default. */ - defaultEffort?: string -} - -/** One model displayed inside its provider group. */ -export interface ModelCatalogModel { - /** Provider-owned model id. */ - id: string - /** Provider-supplied display name. */ - name: string - /** Optional provider-supplied description. */ - description?: string - /** Exact-route reasoning metadata when the adapter exposes it. */ - reasoning?: ModelReasoning -} - -/** One provider and the models it advertised successfully. */ -export interface ModelProviderGroup { - /** Provider route id used for requests. */ - id: string - /** Provider display name. */ - name: string - /** Models in provider-preferred order. */ - models: ModelCatalogModel[] -} - -/** A provider whose asynchronous catalog lookup failed. */ -export interface ModelCatalogFailure { - /** Provider route id. */ - id: string - /** Provider display name. */ - name: string - /** Lookup failure diagnostic. */ - message: string -} - -/** Detached model-directory snapshot for one session. */ -export interface SessionModels { - /** Model selection for the session's next assembled step. */ - current: ModelSelection - /** - * Whether an adapter currently serves `current.provider`, and therefore - * whether this session can start a turn at all. Deliberately NOT derivable - * from `groups`: catalog membership is advisory, so a route serving a model - * it stopped advertising is absent from the groups yet perfectly usable, - * while a route whose adapter is gone can serve nothing. A surface that - * blocks input must read this rather than the groups. - */ - routable: boolean - /** Successfully loaded provider groups. */ - groups: ModelProviderGroup[] - /** Provider-local failures; successful groups remain usable. */ - failures: ModelCatalogFailure[] -} - -/** A client-requested mutation of one still-pending queue item. */ -export type QueueAction = - | { kind: 'edit'; content: ContentBlock[] } - | { kind: 'remove' } - | { kind: 'steer' } - -/** One Session list entry. */ -export interface SessionSummary { - sessionId: SessionId - /** - * The later of creation and the latest human-authored prompt. Attached - * Sessions fold their live log; cold Sessions use a projection-cache hint or - * an exact small-artifact read, falling back to creation time. - */ - updatedAt: number - /** Status of the attached agent; always false for cold (unattached) sessions. */ - running: boolean - /** - * Derived conversation-not-started bit: true while no turn has run. - * Standalone plugin events — command lifecycle - * records, plan/mode, titles, goals — do not open a turn and therefore do - * not clear it. Clients hide blank Sessions from lists and reuse them for - * New Session on the same workspace. A cold Session is true only when a - * small-artifact read verifies that no `turn/start` exists; unavailable - * or oversized artifacts conservatively report false. - */ - blank: boolean - /** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */ - parentSessionId?: SessionId - /** Coarse durable origin used by navigation surfaces; never proves resumability. */ - origin?: 'subagent' - /** Session working directory (header.cwd passthrough); absent when unrecorded. */ - cwd?: string - /** - * Agent preset this session's agent was composed from (header passthrough); - * absent when the deployment composes no presets. A surface offering a - * switch reads this to show what the session actually runs rather than what - * the deployment currently defaults to. - */ - agentPreset?: string - /** - * Projection baseline for this row, with zero log loads: attached sessions - * read the registry's live watermark cut; cold sessions read the persisted - * projection cache's stored rows — as stale as that session's last durable - * checkpoint (`asOfSeq` says exactly how stale), never wrong, and directly - * seedable into the client's per-session value store under its - * higher-seq-wins rule (a list baseline can never overwrite a newer push - * frame). Absent when no value is available (no registry, no cache row for - * a cold session, or a fail-soft cache read miss); a listing client treats - * absence as "no title yet", exactly like a blank session. - */ - projections?: SessionProjectionsBlock -} - -/** One session-content search result; display metadata stays owned by `session.list`. */ -export interface SessionSearchItem { - sessionId: SessionId - /** Plain-text excerpt around the strongest matching visible message. */ - snippet: string -} - -/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ -export interface SessionsApi { - /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ - list(request: RpcRequest<{ cursor?: string }>): Promise> - - /** - * Searches the current user/assistant/steering message surface across - * sessions visible to `list`. Results contain at most 20 sessions and carry - * no continuation cursor; `hasMore` asks the client to refine the query. - */ - search( - request: RpcRequest<{ query: string }>, - signal: AbortSignal, - ): Promise> - - /** - * Creates a real session and its idle agent. At most one of `workspaceId` / - * `cwd` is accepted; an omitted project uses the Host cwd. A caller may - * preallocate `sessionId`: retries with the same id and cwd return the same - * session, while a different cwd fails with `session-conflict`. Workspace - * creation attaches the session after publication; an attach failure - * returns `workspace-attach-failed` with the published session id. - * - * `agentPreset` names the composition the new session's agent is built - * from; omitted, the effective default applies — the user's stored choice - * where one exists, else the deployment's own. The resolved id is stored on - * the session header, so a later resume rebuilds the same agent. An unknown - * id fails with `agent-preset-not-found`, and a preset whose composition - * cannot be mounted fails with `agent-preset-invalid`. - */ - create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>): - Promise> - - /** - * Reads a window of history events; page boundaries align to append-origin message - * boundaries: one page = all raw events owned by a whole number of such messages (including - * their chunk / tool events), never cut mid-message. Model-only replacement copies consume no - * `maxMessages`, so a compaction's `compaction/summary` record stays on the page of its replacement. The tail - * page (beforeSeq absent) additionally carries the in-flight - * partial — chunk events already emitted for the last unfinalized message. - * Each entry pairs the raw SessionEvent with the host-computed view (tool events whose - * presenter produced one, evaluated against the registry at pagination time); the client - * rebuilds the surface from the events with the shared fold. - * The tail page — and only the tail page — additionally carries `projections` - * when the deployment mounts the session-projection registry: every moment - * the client needs a fresh baseline already pulls the tail page, and - * loadOlder (the only beforeSeq path) is the only path that never needs one. - * A deployment without the registry serves histories without the block. - * Reading history uses an attached Session or persistence inspection and - * never resumes or publishes an Agent. - */ - history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): - Promise> - - /** - * Reads a fresh advisory model directory for an ordinary session. Provider - * lookups run independently; subagents reject with `agent-busy`. - */ - models(request: RpcRequest<{ sessionId: SessionId }>): Promise> - - /** - * Selects the complete model selection for this session. Exact model metadata - * validates an optional reasoning effort, while catalog membership remains - * advisory. Session-backed subagents reject with `agent-busy`. - */ - selectModel(request: RpcRequest<{ - sessionId: SessionId - provider: string - model: string - reasoningEffort?: string - }>): - Promise> - - /** - * Renames a session: appends a `session/title` event with the `user` - * source, which pins the title against automatic regeneration. The - * normalized accepted title and the title event's seq return so the caller - * can settle its projection cell without waiting for the push frame. A - * title that normalizes to empty fails with `title-invalid`. - * Session-backed subagents reject with `agent-busy`. - */ - rename(request: RpcRequest<{ sessionId: SessionId; title: string }>): - Promise> - - /** - * Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. - * A prompt whose content is exactly one text block starting with '/' is a slash command: the host - * executes it through the command registry (mode-agnostic) and it is never sent to the model. A - * successful command returns ok with the command slot (its success text, when the command produced - * one — carried for future rendering; the state change is the feedback). A usage/state error is an - * RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command. - */ - /** - * Forks a new session from a completed-turn prefix of the source. `atSeq` - * anchors the cut: the boundary is the first `turn/end` at or after it - * (a message's fork button passes the message seq, so the fork includes - * that whole turn); a boundary past the log end, or an omitted `atSeq`, - * falls back to the source's last completed turn. An in-log anchor whose - * turn is still open fails with `fork-unavailable` instead of clipping to - * an earlier turn. The child inherits the source cwd, latest logged model - * target and `parentSessionId` lineage; the seed prefix carries the source - * title. Reading the source uses attached state or persistence inspection - * without acquiring an Agent. Workspace attachment follows the source - * directly, or the nearest workspace-owning ancestor when the source is a - * subagent. - */ - fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>): - Promise> - - /** - * Sends text and temporary image bytes to an ordinary session Agent after durable host admission. - * Browser callers attach their current IANA zone; - * the Host validates, canonicalizes, and records it on that exact user message. Omission remains - * valid for non-browser callers. Session-backed subagents reject with `agent-busy` and use - * `subagent.prompt`. - */ - prompt(request: RpcRequest<{ - sessionId: SessionId - mode: 'queue' | 'steer' - content: PromptContentPart[] - clientTimeZone?: string - }>): - Promise> - - /** Reads one durable image after proving that this session's log references its id. */ - attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }>): - Promise> - - /** - * Edits, removes, or strictly steers one pending queued occurrence on an ordinary session. - * Session-backed subagents reject with `agent-busy`. - */ - updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: MessageId; action: QueueAction }>): - Promise> - - /** - * Stops an ordinary session's active turn, preserving pending inbox work - * that resumes in FIFO order after cancellation settles. Session-backed - * subagents reject with `agent-busy`. - */ - cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> - -} diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index 747bf19bad..a0a54b6e9f 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' +import { sessionIdSchema } from './ids.schema.ts' import type { SkillEntry } from './skills.ts' /** SkillEntry row of skill.list. */ diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 3b3e711a93..61744f05c9 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -22,10 +22,10 @@ export interface SkillEntry { /** * Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing - * is the domain's only RPC: invocation itself is a plain `session.prompt` - * whose leading `/name` token the host recognizes at the pre-step boundary - * (`dsh-tool-skill` injects the rendered body there), so every client shares - * one deterministic path with no dedicated invocation wire. + * is the domain's only RPC: invocation uses Session Controller's ordinary + * prompt Remote. The host recognizes its leading `/name` token at the pre-step + * boundary (`dsh-tool-skill` injects the rendered body there), so every client + * shares one deterministic path with no dedicated invocation method. */ export interface SkillsApi { /** Lists the user-invocable skill catalog for the session's project. */ diff --git a/packages/host/apiproxy/src/api/subagents.schema.ts b/packages/host/apiproxy/src/api/subagents.schema.ts index 54cbcb2d9b..0943c6c79f 100644 --- a/packages/host/apiproxy/src/api/subagents.schema.ts +++ b/packages/host/apiproxy/src/api/subagents.schema.ts @@ -4,11 +4,11 @@ import { z } from 'zod' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { - contentBlockSchema, historyEntrySchema, sessionIdSchema, sessionProjectionsBlockSchema, -} from './sessions.schema.ts' +import { sessionIdSchema } from './ids.schema.ts' import type { SubagentListEntry } from './subagents.ts' +const contentBlockSchema = z.looseObject({ type: z.string() }) + /** Healthy and diagnostic durable catalog rows. */ export const subagentListEntrySchema = z.union([ z.object({ @@ -45,22 +45,6 @@ export const subagentListValueSchema = z.object({ parentAvailable: z.boolean(), }) satisfies z.ZodType>> -/** subagent.history request payload. */ -export const subagentHistoryRequestSchema = z.object({ - parentSessionId: sessionIdSchema, - childSessionId: sessionIdSchema, - mode: z.union([z.literal('one-shot'), z.literal('continuable')]), - beforeSeq: z.number().int().nonnegative().optional(), - maxMessages: z.number().int().positive().optional(), -}) satisfies z.ZodType>> - -/** subagent.history response value. */ -export const subagentHistoryValueSchema = z.object({ - events: z.array(historyEntrySchema), - hasMore: z.boolean(), - projections: sessionProjectionsBlockSchema.optional(), -}) as unknown as z.ZodType>> - /** subagent.prompt request payload. */ export const subagentPromptRequestSchema = z.object({ parentSessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index 751d48215c..2ca814066b 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -1,14 +1,9 @@ -/** - * Browser-safe subagent domain contract. Persisted transcript reads never - * activate an Agent, while continuable prompts route through the exact live - * direct parent into the child's Agent inbox. - */ +/** Browser-safe subagent catalog, continuation, and interrupt contract. */ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcRequest, RpcResponse } from './rpc.ts' -import type { HistoryEntry, SessionProjectionsBlock } from './sessions.ts' /** Complete durable direct-child catalog row. */ export type SubagentListEntry = @@ -74,20 +69,6 @@ export interface SubagentsApi { signal?: AbortSignal, ): Promise> - /** - * Reads one healthy catalog child's transcript — the in-memory snapshot of - * a live child, the persisted log of a cold one — with ordinary - * message-aligned pagination and render intents, without Agent activation. - */ - history( - request: RpcRequest, - signal?: AbortSignal, - ): Promise> - /** * Delivers human content to a continuable child through the exact live * parent's continuation owner. Success identifies the message accepted by diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts deleted file mode 100644 index b57305141c..0000000000 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * workspace domain zod schemas (names derived from map keys). The - * WorkspaceId brand cast lives in sessions.schema (see the note there) and - * is re-exported here as the domain-local name. - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import type { WorkspaceView } from './workspace.ts' -import { sessionIdSchema, workspaceIdSchema } from './sessions.schema.ts' - -export { workspaceIdSchema } from './sessions.schema.ts' - -/** WorkspaceView row of every workspace.* response. */ -export const workspaceViewSchema = z.object({ - workspaceId: workspaceIdSchema, - path: z.string(), - title: z.string(), - sessionIds: z.array(sessionIdSchema), - createdAt: z.string(), - updatedAt: z.string(), -}) satisfies z.ZodType> - -/** workspace.list request payload (empty object literal). */ -export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType>> - -/** workspace.list response value. */ -export const workspaceListValueSchema = z.object({ - items: z.array(workspaceViewSchema), - archivedSessionIds: z.array(sessionIdSchema), -}) satisfies z.ZodType>> - -/** workspace.create request payload: the existing directory to adopt. */ -export const workspaceCreateRequestSchema = z.object({ - path: z.string(), -}) satisfies z.ZodType>> - -/** workspace.create response value. */ -export const workspaceCreateValueSchema = z.object({ - workspace: workspaceViewSchema, - created: z.boolean(), -}) satisfies z.ZodType>> - -/** workspace.rename request payload: the new title must be non-blank. */ -export const workspaceRenameRequestSchema = z.object({ - workspaceId: workspaceIdSchema, - title: z.string(), -}).refine( - payload => payload.title.trim() !== '', - { message: 'workspace.rename requires a non-blank title' }, -) satisfies z.ZodType>> - -/** workspace.rename response value. */ -export const workspaceRenameValueSchema = z.object({ - workspace: workspaceViewSchema, -}) satisfies z.ZodType>> - -/** workspace.delete request payload. */ -export const workspaceDeleteRequestSchema = z.object({ - workspaceId: workspaceIdSchema, -}) satisfies z.ZodType>> - -/** workspace.delete response value. */ -export const workspaceDeleteValueSchema = z.object({ - deleted: z.literal(true), -}) satisfies z.ZodType>> - -/** workspace.insertBefore request payload (anchor omitted = append to end). */ -export const workspaceInsertBeforeRequestSchema = z.object({ - workspaceId: workspaceIdSchema, - beforeWorkspaceId: workspaceIdSchema.optional(), -}) satisfies z.ZodType>> - -/** workspace.insertBefore response value: the complete durable display order. */ -export const workspaceInsertBeforeValueSchema = z.object({ - workspaceIds: z.array(workspaceIdSchema), -}) satisfies z.ZodType>> - -/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ -export const workspaceInsertSessionBeforeRequestSchema = z.object({ - workspaceId: workspaceIdSchema, - sessionId: sessionIdSchema, - beforeSessionId: sessionIdSchema.optional(), -}) satisfies z.ZodType>> - -/** workspace.insertSessionBefore response value. */ -export const workspaceInsertSessionBeforeValueSchema = z.object({ - workspace: workspaceViewSchema, -}) satisfies z.ZodType>> - -/** workspace.archiveSession request payload. */ -export const workspaceArchiveSessionRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** workspace.archiveSession response value: the full updated archive set. */ -export const workspaceArchiveSessionValueSchema = z.object({ - archivedSessionIds: z.array(sessionIdSchema), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts deleted file mode 100644 index d36d0c406e..0000000000 --- a/packages/host/apiproxy/src/api/workspace.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * workspace domain contract. Wire projection of the host-side workspace - * entity (@deepseek-ai/dsh-workspace): a stable id over a directory path, - * a display title, and the ordered session account. Method signatures are the - * source of truth, same as the sessions domain. - */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { Branded } from '@deepseek-ai/dsh-brand' -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** - * Wire-side workspace id brand. Deliberately re-declared here rather than - * imported from dsh-workspace: api/ must stay browser-importable with zero - * host-package dependencies, and the brand string matches, so both sides - * agree structurally. - */ -export type WorkspaceId = Branded<'WorkspaceId'> - -/** One workspace row: the record projection every workspace.* value carries. */ -export interface WorkspaceView { - workspaceId: WorkspaceId - /** Canonical directory path (host-side realpath canon). */ - path: string - /** Display title (defaults to the path basename at create). */ - title: string - /** - * Sessions accounted under this workspace, in manually owned order - * (attach prepends, insertSessionBefore reorders; activity never does). - */ - sessionIds: SessionId[] - /** ISO-8601 creation instant. */ - createdAt: string - /** ISO-8601 last-mutation instant. */ - updatedAt: string -} - -/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */ -export interface WorkspaceApi { - /** - * Lists all workspaces in the registry's durable display order, plus the - * registry-global archive set (the reconnect baseline of - * `host/archived-sessions-changed`). Archived sessions stay in their - * workspace's `sessionIds` account; grouping surfaces hide them. - */ - list(request: RpcRequest<{}>): Promise> - - /** - * Creates (or idempotently resolves) a workspace over an EXISTING directory - * (no mkdir — a missing or non-directory path fails with - * `workspace-invalid-path`). A path resolving to a directory already owned - * by a workspace returns that workspace (`created: false`). Adoption allows - * distinct canonical paths whose basenames produce the same display title; - * the registry's basename title default names the new workspace. - */ - create(request: RpcRequest<{ path: string }>): - Promise> - - /** - * Renames a workspace. `title` is trimmed and must be non-empty - * (schema-enforced). An unknown id fails with `workspace-not-found`; a - * title equal to another workspace's fails with `workspace-name-conflict`. - * Renaming to the current title is a no-op success (no durable write). - */ - rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>): - Promise> - - /** - * Removes one Workspace registration. The directory, every user file, and - * every session log remain untouched; those Sessions consequently become - * ungrouped. An unknown id fails with `workspace-not-found`. - */ - delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): - Promise> - - /** - * Moves one Workspace within the registry display order, - * DOM-insertBefore-like. An omitted anchor appends to the end. - */ - insertBefore(request: RpcRequest<{ - workspaceId: WorkspaceId - beforeWorkspaceId?: WorkspaceId - }>): Promise> - - /** - * Moves an accounted session within its workspace's manual order, - * DOM-insertBefore-like: with `beforeSessionId` the session is inserted - * before that anchor; omitted appends to the end. An unknown workspace - * fails with `workspace-not-found`; a session or anchor not accounted by - * the workspace fails with `workspace-move-invalid`. A move to the current - * position is a no-op success. - */ - insertSessionBefore(request: RpcRequest<{ - workspaceId: WorkspaceId - sessionId: SessionId - beforeSessionId?: SessionId - }>): Promise> - - /** - * Adds one session to the registry-global archive set: the session - * disappears from every grouping surface but keeps its session log and its - * workspace accounting slot (a future unarchive restores its position). - * Idempotent for an already archived id. A session neither live nor in - * session persistence fails with `session-not-found`. Returns the full - * updated set (same snapshot the changed frame carries). - */ - archiveSession(request: RpcRequest<{ sessionId: SessionId }>): - Promise> -} diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 002e2e9b09..8cb839d20a 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -1,46 +1,21 @@ /** - * Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting, - * four-quadrant envelope wrap/unwrap, zod parsing, in-process SSE frame decoding, and the payload-direct + * Client side of the fetch carrier. AbstractApiClient holds request correlation, + * envelope wrap/unwrap, zod parsing, and the payload-direct * IApiClient domain methods (business code never mints). Platform differences ride two aspects: * abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched. */ import type { z } from 'zod' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { ApiProxy, HostFrame, MuxFrame } from '../api/index.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' -import type { ClientRequest, ClientResponse, RpcMessage, RpcReceipt, RpcRequest, RpcResponse, ServerRequest } from '../api/rpc.ts' +import type { ClientRequest, RpcMessage, RpcResponse } from '../api/rpc.ts' import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' -import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts' -import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts' +import { serverResponseSchema } from '../api/rpc.schema.ts' import { hostCreateDirectoryValueSchema, hostDescribeValueSchema, hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema, } from '../api/host.schema.ts' -import { - sessionCancelValueSchema, - sessionAttachmentValueSchema, - sessionCreateValueSchema, - sessionForkValueSchema, - sessionHistoryValueSchema, - sessionListValueSchema, - sessionModelsValueSchema, - sessionPromptValueSchema, - sessionRenameValueSchema, - sessionSearchValueSchema, - sessionSelectModelValueSchema, - sessionUpdateQueueValueSchema, -} from '../api/sessions.schema.ts' -import { - workspaceArchiveSessionValueSchema, - workspaceCreateValueSchema, - workspaceDeleteValueSchema, - workspaceInsertBeforeValueSchema, - workspaceInsertSessionBeforeValueSchema, - workspaceListValueSchema, - workspaceRenameValueSchema, -} from '../api/workspace.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, @@ -63,7 +38,6 @@ import { } from '../api/credentials.schema.ts' import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' import { - subagentHistoryValueSchema, subagentInterruptValueSchema, subagentListValueSchema, subagentPromptValueSchema, @@ -73,36 +47,17 @@ import { * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary * methods take the business payload directly — the carrier mints the rpcId and wraps the * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo. - * Unary methods and respond accept an optional external AbortSignal as the last parameter. + * Unary methods accept an optional external AbortSignal as the last parameter. * Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls * carry only that external signal. In both cases the signal rides beside the request, never * on the wire, like the stream signatures. - * Stream methods accept an optional onOpen callback: it fires once the physical transport is - * readable (before any frame) — the "stream established" signal - * connection controllers need for the readiness handshake. Generators are lazy, so the - * underlying fetch (and therefore onOpen) only happens once iteration starts. * Relationship: ApiProxy is the narrow-form signature contract the impl side implements; * IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two. * Derived per method key from RpcMethodMap so a map row addition updates this mechanically. */ export interface IApiClient { - sessions: { - list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise>> - search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise>> - create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise>> - history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> - models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise>> - selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise>> - rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise>> - fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise>> - prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> - attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise>> - updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise>> - cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> - } subagents: { list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise>> - history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise>> interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise>> } @@ -113,15 +68,6 @@ export interface IApiClient { createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise>> openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise>> } - workspace: { - list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> - create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> - rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> - delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> - insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise>> - insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> - archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> - } skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } @@ -133,10 +79,6 @@ export interface IApiClient { openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise>> remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise>> } - events: { - mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> - host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> - } goals: { create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise>> edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise>> @@ -162,8 +104,6 @@ export interface IApiClient { models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise>> discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise>> } - /** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */ - respond(message: ClientResponse, signal?: AbortSignal): Promise } /** @@ -171,20 +111,7 @@ export interface IApiClient { * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap). */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { - 'session.list': sessionListValueSchema, - 'session.search': sessionSearchValueSchema, - 'session.create': sessionCreateValueSchema, - 'session.history': sessionHistoryValueSchema, - 'session.models': sessionModelsValueSchema, - 'session.selectModel': sessionSelectModelValueSchema, - 'session.rename': sessionRenameValueSchema, - 'session.fork': sessionForkValueSchema, - 'session.prompt': sessionPromptValueSchema, - 'session.attachment': sessionAttachmentValueSchema, - 'session.updateQueue': sessionUpdateQueueValueSchema, - 'session.cancel': sessionCancelValueSchema, 'subagent.list': subagentListValueSchema, - 'subagent.history': subagentHistoryValueSchema, 'subagent.prompt': subagentPromptValueSchema, 'subagent.interrupt': subagentInterruptValueSchema, 'host.describe': hostDescribeValueSchema, @@ -192,13 +119,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType void>() - /** @param timeoutMs - timeout for bounded unary calls; user-paced calls and streams do not use it. */ + /** @param timeoutMs - timeout for bounded unary calls; user-paced calls do not use it. */ constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */ @@ -303,12 +223,12 @@ export abstract class AbstractApiClient implements IApiClient { } /** - * Shared POST leg of both C→S carriers (callUnary/respond): JSON body, + * Shared POST leg of unary calls: JSON body, * optional default timeout merged with the caller's external signal, non-2xx → transport throw. */ private async postJson( path: string, - body: ClientRequest | ClientResponse, + body: ClientRequest, signal: AbortSignal | undefined, timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise { @@ -351,84 +271,10 @@ export abstract class AbstractApiClient implements IApiClient { return { rpcId: full.rpcId, result: { ok: true, value } } } - /** Mux stream opener; virtual for the same override reason as callUnary. */ - protected openMux(_payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> { - return this.readSse('/api/events.mux', signal, muxFrameSchema, onOpen) - } - - /** Host stream opener; virtual. */ - protected openHost(_payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> { - return this.readSse('/api/events.host', signal, hostFrameSchema, onOpen) - } - - /** - * SSE protocol path: streaming fetch (not EventSource), '\n\n' framing, ServerRequest envelope + - * frame-schema parse, tap, narrow yield. onOpen fires once the response headers are in and the - * body is readable — the stream-established signal, before any frame arrives. A frame that fails - * either parse level is reported and skipped (one corrupt frame must not kill the stream; the - * client's gap detection covers whatever the frame carried). - */ - protected async *readSse( - path: string, - signal: AbortSignal, - frameSchema: z.ZodType, - onOpen?: () => void, - ): AsyncGenerator> { - const response = await this.doFetch(new URL(path, this.resolveBase()), { signal }) - if (!response.ok || response.body === null) throw new Error(`transport failure for ${path}: HTTP ${response.status}`) - onOpen?.() - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - try { - while (true) { - const { done, value } = await reader.read() - if (done) return - buffer += decoder.decode(value, { stream: true }) - let boundary: number - while ((boundary = buffer.indexOf('\n\n')) !== -1) { - const chunk = buffer.slice(0, boundary) - buffer = buffer.slice(boundary + 2) - const data = chunk.split('\n').filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('') - if (data === '') continue - let full: ServerRequest - let frame: F - try { - full = serverRequestSchema.parse(JSON.parse(data)) - frame = frameSchema.parse(full.payload) - } catch (error) { - console.error(`[apiproxy] dropping malformed SSE frame on ${path}:`, error) - continue - } - this.onEnvelope(full) - yield { rpcId: full.rpcId, payload: frame } - } - } - } finally { - await reader.cancel().catch(() => undefined) - } - } - // ---- IApiClient API (arrow properties so destructured/passed references stay bound) ---- - readonly sessions: IApiClient['sessions'] = { - list: (payload, signal) => this.callUnary('session.list', payload, signal), - search: (payload, signal) => this.callUnary('session.search', payload, signal), - create: (payload, signal) => this.callUnary('session.create', payload, signal), - history: (payload, signal) => this.callUnary('session.history', payload, signal), - models: (payload, signal) => this.callUnary('session.models', payload, signal), - selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal), - rename: (payload, signal) => this.callUnary('session.rename', payload, signal), - fork: (payload, signal) => this.callUnary('session.fork', payload, signal), - prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), - attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal), - updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal), - cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), - } - readonly subagents: IApiClient['subagents'] = { list: (payload, signal) => this.callUnary('subagent.list', payload, signal), - history: (payload, signal) => this.callUnary('subagent.history', payload, signal), prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal), interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal), } @@ -445,16 +291,6 @@ export abstract class AbstractApiClient implements IApiClient { openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), } - readonly workspace: IApiClient['workspace'] = { - list: (payload, signal) => this.callUnary('workspace.list', payload, signal), - create: (payload, signal) => this.callUnary('workspace.create', payload, signal), - rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), - delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), - insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal), - insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), - archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), - } - readonly skills: IApiClient['skills'] = { list: (payload, signal) => this.callUnary('skill.list', payload, signal), } @@ -502,16 +338,6 @@ export abstract class AbstractApiClient implements IApiClient { discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal), } - readonly events: IApiClient['events'] = { - mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen), - host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen), - } - - async respond(message: ClientResponse, signal?: AbortSignal): Promise { - this.onEnvelope(message) - const response = await this.postJson('/api/respond', message, signal) - return rpcReceiptSchema.parse(await response.json()) - } } /** diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 697171ec53..7e3a86cfe6 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -6,43 +6,19 @@ * business errors are always 200 + ServerResponse. */ -import { randomUUID } from 'node:crypto' import type { z } from 'zod' -import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts' +import type { ApiProxy } from '../api/index.ts' import { sessionLogQuerySchema } from '../api/downloads.schema.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' -import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts' +import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse } from '../api/rpc.ts' import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' -import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts' -import { - sessionCancelRequestSchema, - sessionAttachmentRequestSchema, - sessionCreateRequestSchema, - sessionForkRequestSchema, - sessionHistoryRequestSchema, - sessionListRequestSchema, - sessionModelsRequestSchema, - sessionPromptRequestSchema, - sessionRenameRequestSchema, - sessionSearchRequestSchema, - sessionSelectModelRequestSchema, - sessionUpdateQueueRequestSchema, -} from '../api/sessions.schema.ts' +import { clientRequestSchema } from '../api/rpc.schema.ts' import { hostCreateDirectoryRequestSchema, hostDescribeRequestSchema, hostListDirectoryRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema, } from '../api/host.schema.ts' -import { - workspaceArchiveSessionRequestSchema, - workspaceCreateRequestSchema, - workspaceDeleteRequestSchema, - workspaceInsertBeforeRequestSchema, - workspaceInsertSessionBeforeRequestSchema, - workspaceListRequestSchema, - workspaceRenameRequestSchema, -} from '../api/workspace.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, @@ -65,7 +41,6 @@ import { } from '../api/credentials.schema.ts' import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' import { - subagentHistoryRequestSchema, subagentInterruptRequestSchema, subagentListRequestSchema, subagentPromptRequestSchema, @@ -88,20 +63,7 @@ type UnaryRoutes = { } const UNARY_ROUTES: UnaryRoutes = { - 'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) }, - 'session.search': { schema: sessionSearchRequestSchema, invoke: (api, r, signal) => api.sessions.search(r, signal) }, - 'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) }, - 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, - 'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) }, - 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, - 'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) }, - 'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) }, - 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, - 'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) }, - 'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) }, - 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) }, - 'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) }, 'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) }, 'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, @@ -109,13 +71,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, - 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, - 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, - 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, - 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, - 'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) }, - 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, - 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, @@ -191,50 +146,6 @@ async function handleUnary( } } -/** SSE frame: complete the narrow RpcRequest into a ServerRequest full form (method = frame type). */ -function fullFrame(narrow: RpcRequest): ServerRequest { - return { type: 'server-request', rpcId: narrow.rpcId, method: narrow.payload.type, payload: narrow.payload } -} - -/** - * Wrap a frame stream as an SSE Response; stops when req.signal aborts. An - * impl throw mid-stream emits one stream/error frame and then closes. - */ -function sseResponse(frames: AsyncIterable>): Response { - const encoder = new TextEncoder() - const stream = new ReadableStream({ - async start(controller) { - try { - // Send an SSE comment line on open so clients/proxies see a live channel (the host - // stream has no baseline frames and would otherwise emit zero bytes while idle; - // a comment line is not a frame, so client frame parsing skips it naturally). - controller.enqueue(encoder.encode(': connected\n\n')) - for await (const narrow of frames) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame(narrow))}\n\n`)) - } - } catch (error: unknown) { - // Mid-stream impl failure → one stream/error frame, then close: the client must see - // the failure instead of a silent end (which reads as a normal disconnect). A fresh - // rpcId is minted — this is a server-initiated push like any other frame. - const failure: MuxFrame | HostFrame = { type: 'stream/error', error: { code: 'internal', message: String(error), details: {} } } - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame({ rpcId: RpcId(randomUUID()), payload: failure }))}\n\n`)) - } catch { - // Consumer already cancelled the stream: enqueue-after-cancel is the - // only reachable error, and there is no one left to tell. - } - } finally { - try { - controller.close() - } catch { /* already cancelled by the consumer: a double close is the only reachable error */ } - } - }, - }) - return new Response(stream, { - headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, - }) -} - /** * Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient). * @param api - the host-side ApiProxy implementation. @@ -249,14 +160,8 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { const url = new URL(req.url) const path = url.pathname - // No-envelope read channels (SSE GET streams + host-only download): + // No-envelope Host-only download channel: // physical routes that answer directly, without a wire envelope. - if (path === '/api/events.mux' && req.method === 'GET') { - return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) - } - if (path === '/api/events.host' && req.method === 'GET') { - return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) - } if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) { // Query params are a different boundary from the POST envelope, but // the request still casts its brands only through the domain schema. @@ -277,7 +182,7 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { // Cross-site write fence: browsers send "simple" POSTs (text/plain, // form encodings) without a CORS preflight, so a malicious page could // otherwise execute side-effectful RPCs blind — the response stays - // unreadable cross-origin, but session.prompt would still run. Only the + // unreadable cross-origin, but the requested mutation would still run. Only the // JSON media type is accepted; anything else is forced into a preflight // this server never answers. 415 = carrier layer, like the 400 below. const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() @@ -293,12 +198,6 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { return new Response('body is not JSON', { status: 400 }) } - if (path === '/api/respond') { - const parsed = clientResponseSchema.safeParse(body) - if (!parsed.success) return Response.json({ accepted: false, reason: 'bad-response' }) - return Response.json(await api.respond(parsed.data)) - } - const method = methodFor(path.slice('/api/'.length)) if (method === undefined) return new Response('not found', { status: 404 }) diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index ac6c770801..546ad8d336 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -7,16 +7,16 @@ * `ctx.apiProxy`). Transport-agnostic by design: this package registers no * routes — physical carriers wrap `ctx.apiProxy` themselves. * - * The gateway consumes `ctx.agentDefaultModel`, the transport-independent default - * shared with direct entry points. Switching models persists through that - * service; sessions that have already logged a selection remain unchanged. + * The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata + * returned by `host.describe`; Session Controller owns Session model selection. */ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' +import type {} from '@deepseek-ai/dsh-api-session-controller' import type { ApiProxy } from './api/index.ts' -import { createApiProxy, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './api-proxy.ts' +import { createApiProxy } from './api-proxy.ts' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, type SessionLogCompressionLevel, @@ -53,35 +53,26 @@ export interface Config { * @default 6 */ sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - /** - * Maximum physical size of a cold Session artifact eligible for blankness - * verification. Zero disables probes. - * @default 1024 - */ - coldBlankProbeMaxBytes?: number } /** * The API gateway service: implements the ApiProxy contract over the composed - * host context and provides it as `ctx.apiProxy`. The Host cwd is the default - * project directory. + * host context and provides it as `ctx.apiProxy`. Its cwd metadata must match + * the default project directory supplied to Session Controller. */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', - 'tools', 'userQuestions', 'workspaceRegistry', + 'sessionController', ] static Config: z = z.object({ nativeOpen: z.boolean(), sessionExportCompressionLevel: z.number().step(1).min(0).max(9) .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z, - coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), }) - readonly sessions: ApiProxy['sessions'] readonly subagents: ApiProxy['subagents'] - readonly workspace: ApiProxy['workspace'] readonly host: ApiProxy['host'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] @@ -89,27 +80,19 @@ export class ApiProxyService extends Service implements ApiProxy { readonly settings: ApiProxy['settings'] readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] - readonly events: ApiProxy['events'] readonly downloads: ApiProxy['downloads'] - readonly respond: ApiProxy['respond'] constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') const api = createApiProxy(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), - saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection), cwd: process.cwd(), ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, ...(config.sessionExportCompressionLevel === undefined ? {} : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }), - ...(config.coldBlankProbeMaxBytes === undefined - ? {} - : { coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes }), }) - this.sessions = api.sessions this.subagents = api.subagents - this.workspace = api.workspace this.host = api.host this.goals = api.goals this.skills = api.skills @@ -117,11 +100,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.settings = api.settings this.credentials = api.credentials this.llm = api.llm - this.events = api.events this.downloads = api.downloads - // createApiProxy returns closures (no `this` capture), so the bind is - // behavior-neutral. - this.respond = api.respond.bind(api) } } diff --git a/packages/host/apiproxy/src/invariant.ts b/packages/host/apiproxy/src/invariant.ts index ac21250e90..9e9489aaff 100644 --- a/packages/host/apiproxy/src/invariant.ts +++ b/packages/host/apiproxy/src/invariant.ts @@ -16,10 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: this package is the wire contract layer plus the - * host-side gateway over services owned elsewhere — it emits no cordis events - * of its own; the session/agent event streams it projects are asserted by - * their owning packages' companions. rpcId round-trip and schema acceptance - * are enforced at the carrier boundary and exercised by the + * host-side unary gateway over services owned elsewhere. rpcId round-trip and + * schema acceptance are enforced at the carrier boundary and exercised by the * protocol-isomorphism suite. */ const install: InvariantInstaller = () => {} diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 072fd2ab2e..13000567d3 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -1,9 +1,4 @@ -/** - * A session's agent preset is fixed at creation. The gateway records the - * resolved id on the header and refuses to adopt the identity under a different - * one, because the session's history was produced under that preset's tools: - * rebuilding it differently would replay tool calls the new agent cannot make. - */ +/** API Proxy behavior for Agent preset management and preset-scoped catalogs. */ import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -12,9 +7,8 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import { RpcId, type RpcRequest } from '../src/api/rpc.ts' -import type { HostFrame } from '../src/api/events.ts' +import type { ApiProxy } from '../src/api/index.ts' import { InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' @@ -28,6 +22,30 @@ function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload } } +const sessionHarnesses = new WeakMap() + +async function createSession( + api: ApiProxy, + request: { readonly sessionId: SessionId; readonly agentPreset?: string }, +): Promise { + const harness = sessionHarnesses.get(api) + if (harness === undefined) throw new Error('Session test harness is not installed') + const presets = harness.ctx.get('agentPresets') + const agentPreset = presets === undefined + ? undefined + : (await presets.resolve(request.agentPreset)).id + await harness.ctx.agents.create({ + sessionId: request.sessionId, + meta: { + cwd: harness.cwd, + ...(agentPreset === undefined ? {} : { agentPreset }), + }, + ...(agentPreset === undefined || presets === undefined + ? {} + : { setup: async (agentCtx: Context) => { await presets.mount(agentCtx, agentPreset) } }), + }) +} + /** Minimal live agent; the gateway only needs identity and its session. */ function stubAgent(session: Session): Agent { return { id: session.id, session, status: 'idle' } as unknown as Agent @@ -78,10 +96,7 @@ function roster(ids: readonly string[], userIds: readonly string[] = []): unknow // The standing scope key a cold transcript read resolves presenters in. standingKeyFor: (id?: string) => { const wanted = id ?? ids[0] ?? '' - standingKeyRequests.push(wanted) - if (!ids.includes(wanted) || failingStandingKeys.has(wanted)) { - return Promise.reject(new UnknownPresetError(wanted, ids)) - } + if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) let key = standingKeys.get(wanted) if (key === undefined) { key = { agentPreset: wanted } @@ -92,26 +107,20 @@ function roster(ids: readonly string[], userIds: readonly string[] = []): unknow } } -/** Standing keys the roster double minted, and the ids readers asked for. */ +/** Standing keys minted by the roster double. */ const standingKeys = new Map() -const standingKeyRequests: string[] = [] -/** Preset ids whose standing mount the double reports as unusable. */ -const failingStandingKeys = new Set() /** Per-agent service instances a mounted preset would own, keyed by session id. */ const services = new Map>() async function harness( presets?: readonly string[], - persistence?: unknown, options: { userIds?: readonly string[]; defaults?: Record } = {}, ) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) - ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never) if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never) const factory: AgentFactory = { @@ -135,122 +144,35 @@ async function harness( }, } ctx.agents.setFactory(factory) - const api = createApiProxy(ctx, { + ctx.provide('sessionController', { + resolveAgent: (sessionId: SessionId) => { + const agent = ctx.agents.get(sessionId) + return Promise.resolve(agent === undefined + ? { + error: { + code: 'session-not-found', + message: `session "${sessionId}" not found`, + details: { sessionId }, + }, + } + : { agent }) + }, + inspect: (sessionId: SessionId) => { + const session = ctx.sessions.get(sessionId) + if (session === undefined) throw new Error(`session "${sessionId}" not found`) + return Promise.resolve({ meta: session.header, events: [...session.events] }) + }, + } as never) + const defaults = { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, ...options.defaults, - }) + } + const api = createApiProxy(ctx, defaults) + sessionHarnesses.set(api, { ctx, cwd }) return { api, ctx, cwd } } -describe('session.create with an agent preset', () => { - it('records the resolved preset on the session header', async () => { - const { api, ctx } = await harness(['standard', 'minimal']) - - const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'minimal' })) - - expect(created.result.ok).toBe(true) - expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal') - }) - - it('records the default when the caller names none', async () => { - const { api, ctx } = await harness(['standard', 'minimal']) - - await api.sessions.create(request({ sessionId: SessionId('s2') })) - - expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard') - }) - - it('rejects an unknown preset and names the ones that exist', async () => { - const { api } = await harness(['standard']) - - const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' })) - - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.code).toBe('agent-preset-not-found') - }) - - it('refuses to adopt a live session under a different preset', async () => { - const { api } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'minimal' })) - - const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' })) - - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.code).toBe('agent-preset-conflict') - expect(response.result.error.details).toEqual({ - sessionId: 's4', - requestedPreset: 'standard', - existingPreset: 'minimal', - }) - }) - - it('adopts a live session under the preset it SWITCHED to', async () => { - const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) - // Exactly what `agentPreset.select` leaves behind on a blank session: the - // header keeps the creation fact, the log states what the agent runs. - ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) - - const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' })) - const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' })) - - // Comparing against the header would invert both answers: the preset the - // session actually runs would be refused, and the one it left would pass. - expect(adopted.result.ok).toBe(true) - // The echo has to name the same preset the adoption just accepted, or the - // client labels the session with one it has already left — and disagrees - // with the row `session.list` serves for it. - if (!adopted.result.ok) throw new Error('unreachable') - expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' }) - expect(stale.result.ok).toBe(false) - if (stale.result.ok) throw new Error('unreachable') - expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' }) - }) - - it('adopts a live session unchanged when the caller names no preset', async () => { - const { api } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' })) - - // Reconnecting and retrying a create must stay ordinary operations. - const response = await api.sessions.create(request({ sessionId: SessionId('s5') })) - - expect(response.result.ok).toBe(true) - }) - - it('leaves the header preset-less when no roster is composed', async () => { - const { api, ctx } = await harness() - - await api.sessions.create(request({ sessionId: SessionId('s6') })) - - expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() - }) - - it('says why a preset-less session cannot be adopted under one', async () => { - // Two callers reach this: a deployment that composes no roster, and a - // session created before one existed. Both record no preset, so naming - // any is a conflict rather than an adoption — the history was produced - // under a composition this roster cannot name. The message has to say - // that, because "already runs agent preset undefined" reads as a bug. - const { api } = await harness() - await api.sessions.create(request({ sessionId: SessionId('s7') })) - - const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' })) - - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.code).toBe('agent-preset-conflict') - expect(response.result.error.message).toContain('records no agent preset') - expect(response.result.error.details).toEqual({ - sessionId: 's7', - requestedPreset: 'standard', - existingPreset: undefined, - }) - }) -}) - /** * A capability a preset mounts is reachable from nowhere the host normally * looks: an `isolate` realm is what makes it per session. The gateway serves @@ -260,7 +182,7 @@ describe('session.create with an agent preset', () => { describe('a capability the session\'s preset mounts', () => { it('serves the goal RPC from the session\'s own goal service', async () => { const { api } = await harness(['standard']) - await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('g1'), agentPreset: 'standard' }) const ref = { id: GoalId('goal-1'), revision: 1 } const paused: unknown[] = [] services.set('g1', { @@ -277,7 +199,7 @@ describe('a capability the session\'s preset mounts', () => { it('serves the skill catalog from the session\'s own registry', async () => { const { api } = await harness(['standard']) - await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('k1'), agentPreset: 'standard' }) services.set('k1', { skills: { list: () => Promise.resolve([{ @@ -298,7 +220,7 @@ describe('a capability the session\'s preset mounts', () => { it('says so when no composition mounts the capability at all', async () => { const { api } = await harness(['standard']) - await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('n1'), agentPreset: 'standard' }) const response = await api.skills.list(request({ sessionId: SessionId('n1') })) @@ -344,7 +266,7 @@ describe('agentPreset.list', () => { describe('agentPreset.select', () => { it('recomposes a blank session', async () => { const { api } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('sel-1'), agentPreset: 'standard' }) const response = await api.agentPresets.select( request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' })) @@ -354,9 +276,9 @@ describe('agentPreset.select', () => { expect(response.result.value.agentPreset).toBe('minimal') }) - it('records the switch in the log, and the list reads it back', async () => { + it('records the switch in the log', async () => { const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('sel-log'), agentPreset: 'standard' }) await api.agentPresets.select( request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' })) @@ -368,48 +290,11 @@ describe('agentPreset.select', () => { if (session === undefined) throw new Error('unreachable') expect(session.header.agentPreset).toBe('standard') expect(resolveSessionPreset(session)).toBe('minimal') - const listed = await api.sessions.list(request({})) - if (!listed.result.ok) throw new Error('unreachable') - expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset) - .toBe('minimal') - }) - - it('forwards the owner event so clients can drop that session\'s catalogs', async () => { - const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' })) - // The host-stream opener reads the committed-workspace baseline; this - // spec owns preset identity, so the stub suffices (api-proxy-commands - // precedent). - ctx.provide('workspaceRegistry', { list: () => [] } as never) - const abort = new AbortController() - const frames: HostFrame[] = [] - const stream = api.events.host(request({}), abort.signal) - const consume = (async () => { - for await (const frame of stream) { - if (frame.payload.type === 'host/remote-event' - && frame.payload.event === 'agent-preset/selected') frames.push(frame.payload) - } - })() - - // AgentPresets owns the committed-log-to-event mapping; this spec owns the - // forwarding of that event without recreating the owner's implementation. - ctx.emit('agent-preset/selected', SessionId('sel-frame'), 'minimal') - // The queue push is synchronous; one turn lets the async iterator consume - // it before the stream closes. - await new Promise(resolve => setTimeout(resolve, 0)) - abort.abort() - await consume - - // Recomposing registers nothing, so the owner event — not the - // registry-wide commands one — tells clients their cached catalogs are stale. - expect(frames).toEqual([ - { type: 'host/remote-event', event: 'agent-preset/selected', args: ['sel-frame', 'minimal'] }, - ]) }) it('serializes two concurrent selects on one session', async () => { const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('sel-race'), agentPreset: 'standard' }) // Both pass the blank check; unserialized, the second unmount finds no // record because the first already removed it, and two compositions end up @@ -429,7 +314,7 @@ describe('agentPreset.select', () => { it('refuses once the conversation has started', async () => { const { api, ctx } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('sel-2'), agentPreset: 'standard' }) // One turn is enough: the history from here on was produced under // `standard`'s tools, and a swap would strand those tool calls. ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 }) @@ -444,7 +329,7 @@ describe('agentPreset.select', () => { it('reports an unknown preset without disturbing the session', async () => { const { api } = await harness(['standard']) - await api.sessions.create(request({ sessionId: SessionId('sel-3') })) + await createSession(api, { sessionId: SessionId('sel-3') }) const response = await api.agentPresets.select( request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' })) @@ -456,7 +341,7 @@ describe('agentPreset.select', () => { it('reports a deployment that composes no presets', async () => { const { api } = await harness() - await api.sessions.create(request({ sessionId: SessionId('sel-4') })) + await createSession(api, { sessionId: SessionId('sel-4') }) const response = await api.agentPresets.select( request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' })) @@ -547,7 +432,7 @@ describe('authoring over the wire', () => { describe('opening a preset directory', () => { it('hands the resolved directory to the native opener', async () => { const opened: string[] = [] - const { api } = await harness(['standard', 'my-preset'], undefined, { + const { api } = await harness(['standard', 'my-preset'], { userIds: ['my-preset'], defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, }) @@ -563,7 +448,7 @@ describe('opening a preset directory', () => { }) it('answers the path as text where the deployment has no opener', async () => { - const { api } = await harness(['standard', 'my-preset'], undefined, { + const { api } = await harness(['standard', 'my-preset'], { userIds: ['my-preset'], defaults: { canOpenPath: () => false }, }) @@ -578,7 +463,7 @@ describe('opening a preset directory', () => { it('refuses a preset that ships with the deployment', async () => { const opened: string[] = [] - const { api } = await harness(['standard'], undefined, { + const { api } = await harness(['standard'], { defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, }) @@ -594,10 +479,10 @@ describe('opening a preset directory', () => { }) it('reports the roster capability on list', async () => { - const openable = await harness(['standard'], undefined, { + const openable = await harness(['standard'], { defaults: { canOpenPath: () => true }, }) - const headless = await harness(['standard'], undefined, { + const headless = await harness(['standard'], { defaults: { canOpenPath: () => false }, }) @@ -609,7 +494,7 @@ describe('opening a preset directory', () => { }) it('counts an injected opener as openable', async () => { - const { api } = await harness(['standard'], undefined, { + const { api } = await harness(['standard'], { defaults: { openPath: () => Promise.resolve() }, }) @@ -629,7 +514,7 @@ describe('skills over the layered host registry', () => { return Promise.resolve([]) }, } as never) - await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' })) + await createSession(api, { sessionId: SessionId('h1'), agentPreset: 'standard' }) const response = await api.skills.list(request({ sessionId: SessionId('h1') })) @@ -671,60 +556,3 @@ describe('skills over the layered host registry', () => { expect(seen).toEqual([undefined]) }) }) - -describe('session.history presenter scope', () => { - it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => { - const { api } = await harness(['standard', 'minimal']) - await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' })) - // Cold: creation registered a live agent in this harness, so simulate the - // cold path by asking for a session only persistence knows... the harness - // has no persistence, so read the live one and assert no roster query. - standingKeyRequests.length = 0 - const live = await api.sessions.history(request({ sessionId: SessionId('p1') })) - expect(live.result.ok).toBe(true) - // A live agent IS the presenter scope; the roster is not consulted. - expect(standingKeyRequests).toEqual([]) - }) - - it('resolves a switched session from the LOG, not its creation header', async () => { - // The header is a creation fact; a switch while blank is a logged event, - // and every turn after it ran under the newer composition. Reading the - // header would render that history through the older preset's layer, - // where the tools it is made of have no presenter at all. - const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' } - const { api } = await harness(['standard', 'minimal'], { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ - meta, - events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }], - }), - }) - - standingKeyRequests.length = 0 - const response = await api.sessions.history(request({ sessionId: SessionId('p4') })) - - expect(response.result.ok).toBe(true) - expect(standingKeyRequests).toEqual(['minimal']) - }) - - it('serves a COLD transcript whose standing mount is no longer usable', async () => { - // A genuinely cold session: persistence knows it, no live agent exists. - const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' } - const { api } = await harness(['standard'], { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events: [] }), - }) - // The preset broke after the session ran: the roster rejects the mount. - failingStandingKeys.add('standard') - try { - standingKeyRequests.length = 0 - const response = await api.sessions.history(request({ sessionId: SessionId('p3') })) - // Degraded, never failed: the roster WAS asked, and the transcript - // still serves — with the generic cards a viewless entry renders. - expect(standingKeyRequests).toEqual(['standard']) - expect(response.result.ok).toBe(true) - } finally { - failingStandingKeys.delete('standard') - } - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts deleted file mode 100644 index 88ab5ede42..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Approval pending registry over the proxy: an ask through `ctx.approval` - * becomes an answerable `approval/requested` mux frame (stable rpcId, replayed - * verbatim on a later mux open), `respond` routes by the echoed rpcId and - * validates the audit correlation, and the ask's abort signal withdraws the - * question with a broadcast `cancelled`. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' -import ApprovalService from '@deepseek-ai/dsh-user-approval' -import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' -import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId as mintRpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '../src/api-proxy.ts' - -async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(UserQuestionService) - await ctx.plugin(AgentRegistry) - await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - return { ctx, api } -} - -/** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */ -function agentOf(ctx: Context): Agent { - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1 }) - return { session } as unknown as Agent -} - -/** Open a mux stream and capture frames into an array (returns an on-demand waiter). */ -function openMux(api: ApiProxy, abort: AbortController): { frames: MuxFrame[]; envelopes: RpcRequest[]; waitFor(type: MuxFrame['type']): Promise } { - const frames: MuxFrame[] = [] - const envelopes: RpcRequest[] = [] - const waiters: { type: MuxFrame['type']; resolve: (frame: MuxFrame) => void }[] = [] - void (async () => { - for await (const envelope of api.events.mux({ rpcId: mintRpcId('t-mux'), payload: {} }, abort.signal)) { - frames.push(envelope.payload) - envelopes.push(envelope) - for (let i = waiters.length - 1; i >= 0; i -= 1) { - const waiter = waiters[i] as (typeof waiters)[number] - if (waiter.type === envelope.payload.type) { - waiters.splice(i, 1) - waiter.resolve(envelope.payload) - } - } - } - })() - return { - frames, - envelopes, - waitFor: (type) => { - const found = frames.find(frame => frame.type === type) - if (found !== undefined) return Promise.resolve(found) - return new Promise((resolve) => { waiters.push({ type, resolve }) }) - }, - } -} - -function requestedOf(frame: MuxFrame): Extract { - if (frame.type !== 'approval/requested') throw new Error(`expected approval/requested, got ${frame.type}`) - return frame -} - -/** Wait until the stream delivered `count` frames of `type` (bounded poll; waitFor only covers the first). */ -async function waitForCount(mux: { frames: MuxFrame[] }, type: MuxFrame['type'], count: number): Promise { - for (let i = 0; i < 200 && mux.frames.filter(frame => frame.type === type).length < count; i += 1) { - await new Promise(resolve => setTimeout(resolve, 5)) - } - expect(mux.frames.filter(frame => frame.type === type).length).toBeGreaterThanOrEqual(count) -} - -function answer(rpcId: RpcId, sessionId: unknown, approvalId: ApprovalRequestId, outcome: 'allowed-once' | 'rejected'): Parameters[0] { - return { type: 'client-response', rpcId, result: { ok: true, value: { sessionId, approvalId, outcome } } } -} - -describe('approval pending registry', () => { - it('round-trips ask → requested frame → respond → outcome + resolved broadcast', async () => { - const { ctx, api } = await harness() - const abort = new AbortController() - const mux = openMux(api, abort) - const agent = agentOf(ctx) - - const asked = ctx.approval.request({ agent, toolName: 'bash', reason: 'sandbox escalation' }) - const requested = requestedOf(await mux.waitFor('approval/requested')) - expect(requested).toMatchObject({ toolName: 'bash', reason: 'sandbox escalation', sessionId: agent.session.id }) - - const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest - const receipt = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')) - expect(receipt).toEqual({ accepted: true }) - await expect(asked).resolves.toBe('allowed-once') - - const resolved = await mux.waitFor('approval/resolved') - expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'allowed-once' }) - - // The question settled: a duplicate answer is late, not re-decidable. - const dup = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'rejected')) - expect(dup).toEqual({ accepted: false, reason: 'not-pending' }) - abort.abort() - }) - - it('replays a still-pending requested frame (same rpcId) on a later mux open', async () => { - const { ctx, api } = await harness() - const first = new AbortController() - const firstMux = openMux(api, first) - const agent = agentOf(ctx) - const asked = ctx.approval.request({ agent, toolName: 'write' }) - const requested = requestedOf(await firstMux.waitFor('approval/requested')) - const firstEnvelope = firstMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest - first.abort() - - // A fresh subscriber (refresh recovery) sees the same stable rpcId. - const second = new AbortController() - const secondMux = openMux(api, second) - const replayed = requestedOf(await secondMux.waitFor('approval/requested')) - const secondEnvelope = secondMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest - expect(secondEnvelope.rpcId).toBe(firstEnvelope.rpcId) - expect(replayed.approvalId).toBe(requested.approvalId) - - const receipt = await api.respond(answer(secondEnvelope.rpcId, replayed.sessionId, replayed.approvalId, 'rejected')) - expect(receipt).toEqual({ accepted: true }) - await expect(asked).resolves.toBe('rejected') - second.abort() - }) - - it('rejects malformed and mismatched answers as bad-response, unknown ids as not-pending', async () => { - const { ctx, api } = await harness() - const abort = new AbortController() - const mux = openMux(api, abort) - const agent = agentOf(ctx) - void ctx.approval.request({ agent, toolName: 'bash' }) - const requested = requestedOf(await mux.waitFor('approval/requested')) - const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest - - // Unknown rpcId: not routed to any pending entry. - expect(await api.respond(answer(mintRpcId('ghost'), requested.sessionId, requested.approvalId, 'rejected'))) - .toEqual({ accepted: false, reason: 'not-pending' }) - // Error-branch result: the client can only answer with a value. - expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - // Wrong audit correlation: the rpcId routed, but the payload disagrees. - expect(await api.respond(answer(envelope.rpcId, requested.sessionId, 'other-approval' as ApprovalRequestId, 'rejected'))) - .toEqual({ accepted: false, reason: 'bad-response' }) - // Malformed payload shape. - expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: true, value: { nonsense: 1 } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - abort.abort() - }) - - it('withdraws the question on the ask signal: cancelled outcome, resolved broadcast, late answer not-pending', async () => { - const { ctx, api } = await harness() - const abort = new AbortController() - const mux = openMux(api, abort) - const agent = agentOf(ctx) - const cancel = new AbortController() - const asked = ctx.approval.request({ agent, toolName: 'bash', signal: cancel.signal }) - const requested = requestedOf(await mux.waitFor('approval/requested')) - const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest - - cancel.abort() - await expect(asked).resolves.toBe('cancelled') - const resolved = await mux.waitFor('approval/resolved') - expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' }) - expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))) - .toEqual({ accepted: false, reason: 'not-pending' }) - abort.abort() - }) - - it('an ask whose signal aborted before dispatch settles cancelled without publishing', async () => { - // The service checks the signal, then dispatch rides a microtask: an - // abort in that window must not register a dead listener and strand the - // entry (zombie frame on every replay). Drive the waterfall directly - // with a pre-aborted signal to hit the answerer's register-path guard. - const { ctx, api } = await harness() - const abort = new AbortController() - const mux = openMux(api, abort) - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1 }) - session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' }) - const agent = { session } as unknown as Agent - const cancelled = new AbortController() - cancelled.abort() - const outcome = await ctx.waterfall( - 'approval/request', - { agent, toolName: 'bash', signal: cancelled.signal }, - () => Promise.resolve('unavailable' as const), - ) - expect(outcome).toBe('cancelled') - // Nothing was published: a fresh mux open replays no approval frame. - const abort2 = new AbortController() - const mux2 = openMux(api, abort2) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(mux2.envelopes.some(e => e.payload.type === 'approval/requested')).toBe(false) - abort2.abort() - abort.abort() - void mux - }) - - it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => { - // Mount the proxy on its own fiber so disposal exercises the teardown - // effect while an ask is still pending. - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(UserQuestionService) - await ctx.plugin(AgentRegistry) - await ctx.plugin(ApprovalService) - let api!: ApiProxy - const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { - api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - }, { inject: ['sessions', 'agents', 'userQuestions', 'approval'] })) - await fiber.await() - const abort = new AbortController() - const mux = openMux(api, abort) - const asked = ctx.approval.request({ agent: agentOf(ctx), toolName: 'bash' }) - const requested = requestedOf(await mux.waitFor('approval/requested')) - await fiber.dispose() - await expect(asked).resolves.toBe('cancelled') - const resolved = await mux.waitFor('approval/resolved') - expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' }) - abort.abort() - }) - - it('carries callId on the frame and ignores a late abort after the answer settled', async () => { - const { ctx, api } = await harness() - const abort = new AbortController() - const mux = openMux(api, abort) - const agent = agentOf(ctx) - const cancel = new AbortController() - const asked = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-9' as never, signal: cancel.signal }) - const requested = requestedOf(await mux.waitFor('approval/requested')) - expect(requested.callId).toBe('call-9') - const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest - expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))) - .toEqual({ accepted: true }) - await expect(asked).resolves.toBe('allowed-once') - // Late abort: the pending entry is gone; settle's delete-guard returns. - cancel.abort() - expect(mux.frames.filter(f => f.type === 'approval/resolved')).toHaveLength(1) - abort.abort() - }) - - it('pairs parallel asks by callId: each requested frame carries its own audit id', async () => { - const { ctx, api } = await harness() - const abort = new AbortController() - const mux = openMux(api, abort) - const agent = agentOf(ctx) - // Both asks append their approval/asked audit events before either - // answerer's microtask dispatch runs — the parallel tool-call window. - const askA = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-a' as never }) - const askB = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-b' as never }) - await waitForCount(mux, 'approval/requested', 2) - const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested') - const frameA = frames.find(e => requestedOf(e.payload).callId === 'call-a') as RpcRequest - const frameB = frames.find(e => requestedOf(e.payload).callId === 'call-b') as RpcRequest - // Each frame claimed the asked event with its own callId, not merely the newest. - const askedIdByCall = new Map(agent.session.events - .filter(event => event.type === 'approval/asked') - .map(event => [String(event.data.callId), event.data.id])) - expect(requestedOf(frameA.payload).approvalId).toBe(askedIdByCall.get('call-a')) - expect(requestedOf(frameB.payload).approvalId).toBe(askedIdByCall.get('call-b')) - // Answers route back to the right ask through the pairing. - expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected'))) - .toEqual({ accepted: true }) - expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once'))) - .toEqual({ accepted: true }) - await expect(askA).resolves.toBe('allowed-once') - await expect(askB).resolves.toBe('rejected') - abort.abort() - }) - - it('gives parallel callId-less asks distinct audit ids (claimed-entry skip); both stay answerable', async () => { - const { ctx, api } = await harness() - const abort = new AbortController() - const mux = openMux(api, abort) - const agent = agentOf(ctx) - const askA = ctx.approval.request({ agent, toolName: 'alpha' }) - const askB = ctx.approval.request({ agent, toolName: 'beta' }) - await waitForCount(mux, 'approval/requested', 2) - const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested') - const frameA = frames.find(e => requestedOf(e.payload).toolName === 'alpha') as RpcRequest - const frameB = frames.find(e => requestedOf(e.payload).toolName === 'beta') as RpcRequest - // Without a callId the pairing is heuristic, but never shared: the second - // dispatch skips the id the first pending entry already claimed. - expect(requestedOf(frameA.payload).approvalId).not.toBe(requestedOf(frameB.payload).approvalId) - expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once'))) - .toEqual({ accepted: true }) - expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected'))) - .toEqual({ accepted: true }) - await expect(askA).resolves.toBe('allowed-once') - await expect(askB).resolves.toBe('rejected') - abort.abort() - }) - - it('delegates a dispatch whose only asked candidate is already decided (stale re-dispatch)', async () => { - const { ctx, api } = await harness() - void api // the answerer is registered; the fake below bypasses the service - // Bypass ApprovalService: a log whose sole asked event already has its - // decided partner must not be re-claimed — the answerer delegates. - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1 }) - session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' }) - session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' }) - const agent = { session } as unknown as Agent - const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'bash' }, () => Promise.resolve('unavailable' as const)) - expect(outcome).toBe('unavailable') - }) - - it('delegates an ask whose session log carries no asked audit event (foreign channel)', async () => { - const { ctx, api } = await harness() - void api // the answerer is registered; the fake below bypasses the audit path - // Bypass ApprovalService: dispatch the waterfall directly with a session - // that has no approval/asked event — the proxy answerer must call next(). - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1 }) - const agent = { session } as unknown as Agent - const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const)) - expect(outcome).toBe('unavailable') - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 3ba6f3a0af..2916222805 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -1,5 +1,5 @@ /** - * Settings/credentials/llm RPC domains and their host-stream frames over + * Settings/credentials/llm RPC domains and their owner events over * createApiProxy: layered redacted describe, write-path rejection mapping, * value-free credential views, the directory/live-route merge, and the three * invalidation frames (settings/credentials/models changed). @@ -12,7 +12,6 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -27,7 +26,6 @@ import type { CredentialRef, ResolvedCredential, } from '@deepseek-ai/dsh-credentials' -import type { HostFrame } from '../src/api/index.ts' import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' @@ -211,7 +209,6 @@ async function harness(options?: { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) - await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) await ctx.plugin(LlmRuntime) if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) @@ -223,50 +220,55 @@ async function harness(options?: { { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, ]) } - // Host-stream opener reads the committed-workspace baseline; the stub - // suffices — the real workspace composition is api-proxy-workspace.spec's. - ctx.provide('workspaceRegistry', { list: () => [] } as never) return ctx } -/** Drain `count` host frames matching `types`, then abort the stream. */ -async function collectHost( - api: ReturnType, - types: string[], - count: number, +/** Observe settings commits while one API operation runs. */ +async function captureSettingsUpdates( + ctx: Context, run: () => Promise, -): Promise { - const abort = new AbortController() - const frames: HostFrame[] = [] - const stream = api.events.host(request({}), abort.signal) - const consume = (async () => { - for await (const frame of stream) { - if (!types.includes(frame.payload.type)) continue - frames.push(frame.payload) - if (frames.length >= count) abort.abort() - } - })() - await run() - await consume - return frames +): Promise> { + const updates: Array = [] + const dispose = ctx.on('settings/document-updated', (namespace, revision) => { + updates.push([namespace, revision]) + }) + try { + await run() + return updates + } finally { + dispose() + } } -/** - * One forwarded `settings/document-updated` frame for `ns`. The revision rides - * the host's own argument list, so it is matched by shape rather than pinned to - * a per-test count. - * @param ns - the namespace whose stored section changed. - * @returns the expected wrapper frame. - */ -function forwardedSettings(ns: string): HostFrame { - return { - type: 'host/remote-event', - event: 'settings/document-updated', - // The revision is the Host's own counter, so the matcher is the assertion. - args: [ns, expect.any(Number)], // oxlint-disable-line typescript/no-unsafe-assignment +/** Observe credential commits while one API operation runs. */ +async function captureCredentialUpdates(ctx: Context, run: () => Promise): Promise { + const updates: CredentialRef[] = [] + const dispose = ctx.on('credentials/reference-updated', (ref) => { updates.push(ref) }) + try { + await run() + return updates + } finally { + dispose() } } +/** Count model-adapter topology commits while one API operation runs. */ +async function countAdapterUpdates(ctx: Context, run: () => Promise): Promise { + let updates = 0 + const dispose = ctx.on('llm/adapters-updated', () => { updates += 1 }) + try { + await run() + return updates + } finally { + dispose() + } +} + +/** Expected settings event tuple with its owner-assigned revision. */ +function expectedSettingsUpdate(ns: string): readonly unknown[] { + return [ns, expect.any(Number)] +} + describe('settings domain', () => { it('reports an actionable error when no settings provider is mounted', async () => { const ctx = await harness({ settings: false }) @@ -446,7 +448,7 @@ describe('settings domain', () => { const api = createApiProxy(ctx, DEFAULTS) expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) .toEqual(['ui-onboarding', 'ui-theme']) - const frames = await collectHost(api, ['host/remote-event'], 2, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { expectOk(await api.settings.mutate(request({ ns: 'ui-onboarding', ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }], @@ -456,7 +458,10 @@ describe('settings domain', () => { ops: [{ op: 'set', path: ['preference'], value: 'dark' }], }))) }) - expect(frames).toEqual([forwardedSettings('ui-onboarding'), forwardedSettings('ui-theme')]) + expect(updates).toEqual([ + expectedSettingsUpdate('ui-onboarding'), + expectedSettingsUpdate('ui-theme'), + ]) }) it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => { @@ -496,10 +501,10 @@ describe('settings domain', () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event'], 1, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } })) }) - expect(frames).toEqual([forwardedSettings('llm-deepseek')]) + expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')]) // The resolved value never moved: base already said https://base. expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value) .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) @@ -512,11 +517,10 @@ describe('settings domain', () => { }), { base: { defaultPreset: 'read-only' }, }) - const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event'], 1, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { await permission.update({ defaultPreset: 'workspace-write' }) }) - expect(frames).toEqual([forwardedSettings('permission')]) + expect(updates).toEqual([expectedSettingsUpdate('permission')]) }) it('forwards an Agent-default settings change for model-catalog consumers', async () => { @@ -525,14 +529,13 @@ describe('settings domain', () => { provider: z.string().required(), model: z.string().required(), }), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) - const api = createApiProxy(ctx, DEFAULTS) // The shared section names the selection every blank session resolves to, // so an externally edited default — another tab, a // hand-edited settings.yaml — has to reach an open selector as well. - const frames = await collectHost(api, ['host/remote-event'], 1, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) }) - expect(frames).toEqual([forwardedSettings('agent-default-model')]) + expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')]) }) it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { @@ -553,14 +556,14 @@ describe('settings domain', () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event'], 1, async () => { + const updates = await captureSettingsUpdates(ctx, async () => { const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } }))) expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' }) expect(view.user).toEqual({ baseURL: 'https://next' }) expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }]) expect(JSON.stringify(view)).not.toContain('sk-new') }) - expect(frames).toEqual([forwardedSettings('llm-deepseek')]) + expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')]) }) it('replace resets the user layer wholesale', async () => { @@ -624,17 +627,14 @@ describe('credentials domain', () => { const api = createApiProxy(ctx, DEFAULTS) const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } }) - const frames = await collectHost(api, ['host/remote-event'], 2, async () => { + const updates = await captureCredentialUpdates(ctx, async () => { expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' }))) const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } }) expect(JSON.stringify(after)).not.toContain('sk-secret') expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' }))) }) - expect(frames).toEqual([ - { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] }, - { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] }, - ]) + expect(updates).toEqual(['OPENAI_API_KEY', 'OPENAI_API_KEY']) }) it('maps a shadowed write onto credential-rejected for set and unset alike', async () => { @@ -692,16 +692,12 @@ describe('llm domain', () => { it('forwards llm/adapters-updated at every topology commit point', async () => { const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event'], 2, async () => { + const updates = await countAdapterUpdates(ctx, async () => { const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [])) dispose() return Promise.resolve() }) - expect(frames).toEqual([ - { type: 'host/remote-event', event: 'llm/adapters-updated', args: [] }, - { type: 'host/remote-event', event: 'llm/adapters-updated', args: [] }, - ]) + expect(updates).toBe(2) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-host.spec.ts b/packages/host/apiproxy/tests/api-proxy-host.spec.ts new file mode 100644 index 0000000000..74c068150f --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-host.spec.ts @@ -0,0 +1,205 @@ +import { homedir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' +import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +let nextRpc = 1 +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`host-${String(nextRpc++)}`), payload } +} + +function expectOk(response: { readonly result: { readonly ok: true; readonly value: T } | { readonly ok: false } }): T { + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + return response.result.value +} + +async function harness( + picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, + extras: { + openPath?: (path: string, signal: AbortSignal) => Promise + canOpenPath?: () => boolean + } = {}, +) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(AgentRegistry) + ctx.provide('directoryPicker', { capability: () => picker } as never) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), + cwd: '/tmp/dsh-apiproxy-host', + ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, + ...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath }, + }) + return { api } +} + +describe('host.pickDirectory', () => { + it('returns a selected path or explicit cancellation from the native capability', async () => { + const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' }) + expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) + .toEqual({ ok: true, value: { path: '/tmp/project' } }) + + const cancelled = await harness({ kind: 'native', pick: async () => null }) + expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) + .toEqual({ ok: true, value: { path: null } }) + }) + + it('propagates abort into the native capability as a cancelled RPC error', async () => { + const { api } = await harness({ + kind: 'native', + pick: signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) + const abort = new AbortController() + const pending = api.host.pickDirectory(request({}), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) + + it('folds a non-abort native-chooser failure into an internal error', async () => { + const { api } = await harness({ + kind: 'native', + pick: async () => { throw new Error('no chooser installed') }, + }) + const response = await api.host.pickDirectory(request({}), new AbortController().signal) + expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) + }) + + it('refuses the native RPC under a browse composition', async () => { + const { api } = await harness(BROWSE_STUB) + const response = await api.host.pickDirectory(request({}), new AbortController().signal) + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } }, + }) + }) +}) + +const BROWSE_STUB: DirectoryPickerCapability = { + kind: 'browse', + list: async (path) => { + if (path === '/denied') { + throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') + } + const target = path ?? '/home/user' + return { + path: target, + home: '/home/user', + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], + truncated: false, + } + }, + createDirectory: async (path, name) => { + if (name === 'taken') { + throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') + } + if (name === 'unwritable') throw new Error('disk detached') + return `${path}/${name}` + }, +} + +describe('host.listDirectory / host.createDirectory', () => { + it('serves listings and creation through the browse capability, defaulting to home', async () => { + const { api } = await harness(BROWSE_STUB) + const home = await api.host.listDirectory(request({}), new AbortController().signal) + expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) + const listed = await api.host.listDirectory( + request({ path: '/home/user/projects' }), + new AbortController().signal, + ) + expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) + const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) + expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) + }) + + it('maps typed picker failures onto wire errors and folds unknown throws to internal', async () => { + const { api } = await harness(BROWSE_STUB) + expect((await api.host.listDirectory( + request({ path: '/denied' }), + new AbortController().signal, + )).result).toMatchObject({ + ok: false, + error: { code: 'directory-unreadable', details: { path: '/denied' } }, + }) + expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result) + .toMatchObject({ ok: false, error: { code: 'directory-exists' } }) + expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result) + .toMatchObject({ ok: false, error: { code: 'internal' } }) + }) + + it('reports an aborted listing as cancelled', async () => { + const { api } = await harness({ + kind: 'browse', + list: (_path, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) + }), + createDirectory: async () => '/never', + }) + const abort = new AbortController() + const pending = api.host.listDirectory(request({}), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) + + it('refuses the browse RPCs under a native composition', async () => { + const { api } = await harness() + expect((await api.host.listDirectory(request({}), new AbortController().signal)).result) + .toMatchObject({ + ok: false, + error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, + }) + expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result) + .toMatchObject({ + ok: false, + error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, + }) + }) +}) + +describe('host.openPath', () => { + it('describes whether the deployment can reach a native desktop', async () => { + const visible = await harness(undefined, { canOpenPath: () => true }) + const headless = await harness(undefined, { canOpenPath: () => false }) + expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) + expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) + expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) + }) + + it('opens through the injected native boundary', async () => { + const opened: string[] = [] + const { api } = await harness(undefined, { + openPath: async (path) => { opened.push(path) }, + }) + expect((await api.host.openPath( + request({ path: '/tmp/a.txt' }), + new AbortController().signal, + )).result).toEqual({ ok: true, value: { opened: true } }) + expect(opened).toEqual(['/tmp/a.txt']) + }) + + it('propagates abort into the native boundary as a cancelled RPC error', async () => { + const { api } = await harness(undefined, { + openPath: (_path, signal) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) + const abort = new AbortController() + const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts b/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts deleted file mode 100644 index d61e618a96..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-jobs.spec.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Background-task carrier paths of the host ApiProxy: the subscription - * baseline is sent only for a session that has tasks, every registry change - * pushes that owner's whole set, an unowned change fans out to every - * subscribed session, the projection drops the three internal snapshot - * fields, a composition without `ctx.jobs` emits nothing, and listing never - * resumes a cold session. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' -import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import type { JobOutcome } from '@deepseek-ai/dsh-jobs' -import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' - -type JobFrame = Extract - -/** - * A producer whose settlement the test drives. `cancel` deliberately does not - * settle, so a kill is observable as the distinct `stopping` step before the - * test supplies the terminal outcome and its detail. - */ -function producer(label = 'sleep 60') { - let settle!: (outcome: JobOutcome) => void - // A stream producer, so the carrier CAN consume the cursor if it ever calls - // `read()`; `reads` is what proves it never does. - const reads = { count: 0 } - const spec = { - kind: 'bash' as const, - label, - run: () => ({ - cancel: () => {}, - done: new Promise((resolve) => { settle = resolve }), - readOutput: () => { reads.count += 1; return 'stolen output' }, - }), - } - return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } } -} - -async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(UserQuestionService) - await ctx.plugin(AgentRegistry) - if (withRegistry) { - await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('api-proxy-test') - } - const session = ctx.sessions.create() - const agent = { - id: session.id, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx, - } as Agent - ctx.agents.register(agent) - return { ctx, session, agent } -} - -const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - -/** Drain the mux until `count` session/jobs frames arrived, then abort. */ -async function collect( - iterable: AsyncIterable>, - count: number, - abort: AbortController, -): Promise { - const frames: MuxFrame[] = [] - for await (const envelope of iterable) { - frames.push(envelope.payload) - if (frames.filter(frame => frame.type === 'session/jobs').length >= count) abort.abort() - } - return frames.filter((frame): frame is JobFrame => frame.type === 'session/jobs') -} - -describe('session/jobs subscription baseline', () => { - it('is omitted for a session with no tasks — absence is the empty set', async () => { - const { ctx, session } = await harness(true) - const abort = new AbortController() - const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-empty'), payload: {} }, abort.signal) - const frames: MuxFrame[] = [] - const drained = (async () => { - for await (const envelope of stream) { - frames.push(envelope.payload) - if (frames.some(frame => frame.type === 'session/subscribed')) abort.abort() - } - })() - await drained - expect(frames.some(frame => frame.type === 'session/jobs')).toBe(false) - expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true) - void session - }) - - it('carries the live set for a session that already has tasks when the stream opens', async () => { - const { ctx, session, agent } = await harness(true) - ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent }) - const abort = new AbortController() - const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal) - const [baseline] = await collect(stream, 1, abort) - expect(baseline?.sessionId).toBe(session.id) - expect(baseline?.jobs).toHaveLength(1) - const [job] = baseline?.jobs ?? [] - expect(job?.startedAt).toBeTypeOf('number') - expect({ ...job, startedAt: 0 }).toEqual({ - id: 'bash-1', - kind: 'bash', - label: 'pnpm run build', - status: 'running', - startedAt: 0, - }) - }) -}) - -describe('session/jobs change pushes', () => { - it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => { - const { ctx, session, agent } = await harness(true) - const proxy = api(ctx) - const abort = new AbortController() - const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-changes'), payload: {} }, abort.signal) - const collected = collect(stream, 3, abort) - - const p = producer() - const id = ctx.jobs.start({ ...p.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - - const frames = await collected - expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) - expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed']) - // Terminal detail rides the same whole-set push; no separate signal. - expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM') - expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number') - }) - - it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => { - const { ctx, agent } = await harness(true) - const proxy = api(ctx) - const abort = new AbortController() - const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal) - const collected = collect(stream, 1, abort) - ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) - - const [frame] = await collected - const fields: readonly string[] = Object.keys(frame?.jobs[0] ?? {}) - expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status']) - }) - - it('fans an unowned change out to every subscribed session', async () => { - const { ctx } = await harness(true) - const second = ctx.sessions.create() - const proxy = api(ctx) - const abort = new AbortController() - const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal) - const collected = collect(stream, 2, abort) - - ctx.jobs.start(producer('open to every caller').spec) - - const frames = await collected - expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) - expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) - for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller') - }) - - it('serves a cold session the unowned set without resuming it', async () => { - const { ctx } = await harness(true) - const coldId = SessionId('session-cold-tasks') - let loaded = false - ctx.provide('sessionPersistence', { - list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], - locate: () => undefined, - load: () => { loaded = true; throw new Error('task listing must not load a cold log') }, - } as never) - const proxy = api(ctx) - const abort = new AbortController() - const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal) - const collected = collect(stream, 1, abort) - - ctx.jobs.start(producer().spec) - await collected - expect(loaded).toBe(false) - expect(ctx.agents.get(coldId)).toBeUndefined() - }) -}) - -describe('session/jobs without the registry', () => { - it('emits no frames at all, so the client renders no entry point', async () => { - const { ctx, session } = await harness(false) - const proxy = api(ctx) - const abort = new AbortController() - const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-absent'), payload: {} }, abort.signal) - const frames: MuxFrame[] = [] - const drained = (async () => { - for await (const envelope of stream) { - frames.push(envelope.payload) - if (frames.filter(frame => frame.type === 'session/event').length >= 1) abort.abort() - } - })() - session.append('turn/start', { turn: 1 }) - await drained - expect(frames.some(frame => frame.type === 'session/jobs')).toBe(false) - }) -}) - -describe('session/jobs never consumes model output', () => { - it('drives the whole lifecycle without calling the single consuming cursor', async () => { - // `ctx.jobs.read()` consumes the one output cursor, so a carrier read - // silently takes bytes the model's `job_output` will never see. The - // failure is invisible at the call site, which is why this asserts the - // count rather than trusting review. - const { ctx, agent } = await harness(true) - const proxy = api(ctx) - const abort = new AbortController() - const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-no-read'), payload: {} }, abort.signal) - const collected = collect(stream, 3, abort) - - const p = producer() - const id = ctx.jobs.start({ ...p.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - await collected - - expect(p.reads.count).toBe(0) - }) - - it('reads nothing while minting the subscription baseline either', async () => { - const { ctx, agent } = await harness(true) - const p = producer() - ctx.jobs.start({ ...p.spec, owner: agent }) - - const abort = new AbortController() - const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal) - const [baseline] = await collect(stream, 1, abort) - - expect(baseline?.jobs).toHaveLength(1) - expect(p.reads.count).toBe(0) - }) -}) - -describe('session/jobs baseline for a session born after the stream opened', () => { - it('carries the already-visible unowned set to the new session', async () => { - const { ctx } = await harness(true) - const proxy = api(ctx) - const abort = new AbortController() - const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-late-session'), payload: {} }, abort.signal) - - // One unowned task exists before the new session is created; the subscribe - // frame clears the client mirror, so the baseline has to follow it. - ctx.jobs.start(producer('visible to every caller').spec) - const created = ctx.sessions.create() - - const frames = await collect(stream, 2, abort) - const forNew = frames.filter(frame => frame.sessionId === created.id) - expect(forNew.at(-1)?.jobs[0]?.label).toBe('visible to every caller') - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts deleted file mode 100644 index cfb6b9da5a..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' -import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '../src/api-proxy.ts' - -async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) - return { - ctx, - api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), - } -} - -function agent(ctx: Context): Agent { - const session = ctx.sessions.create() - const value = { id: session.id, session, status: 'idle', ctx } as Agent - ctx.agents.register(value) - return value -} - -function openMux(api: ApiProxy, abort: AbortController): { - envelopes: RpcRequest[] - waitForQuestion(): Promise>> -} { - const envelopes: RpcRequest[] = [] - let resolveQuestion!: (value: RpcRequest>) => void - const question = new Promise>>((resolve) => { - resolveQuestion = resolve - }) - void (async () => { - for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) { - envelopes.push(envelope) - if (envelope.payload.type === 'question/requested') { - resolveQuestion(envelope as RpcRequest>) - } - } - })() - return { envelopes, waitForQuestion: () => question } -} - -function answer( - envelope: RpcRequest>, - selected: string[], - custom?: string, -): Parameters[0] { - return { - type: 'client-response', - rpcId: envelope.rpcId, - result: { - ok: true, - value: { - sessionId: envelope.payload.sessionId, - answer: { - answers: [{ - id: envelope.payload.questions[0]?.id, - selected, - ...custom === undefined ? {} : { custom }, - }], - }, - }, - }, - } -} - -describe('question response validation', () => { - it('accepts selected options with custom text for multi-select questions', async () => { - const { ctx, api } = await harness() - const abort = new AbortController() - const mux = openMux(api, abort) - const asked = ctx.userQuestions.ask({ - agent: agent(ctx), - questions: [{ - id: 'targets', - question: 'Choose targets and add another', - multiSelect: true, - options: [{ label: 'Code' }, { label: 'Docs' }], - }], - }) - const envelope = await mux.waitForQuestion() - - expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes'))) - .toEqual({ accepted: true }) - await expect(asked).resolves.toEqual({ - answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }], - }) - expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true) - abort.abort() - }) - - it('keeps selected options and custom text mutually exclusive for single-select questions', async () => { - const { ctx, api } = await harness() - const abort = new AbortController() - const mux = openMux(api, abort) - const asked = ctx.userQuestions.ask({ - agent: agent(ctx), - questions: [{ - id: 'target', - question: 'Choose one target', - options: [{ label: 'Code' }, { label: 'Docs' }], - }], - }) - const envelope = await mux.waitForQuestion() - - expect(await api.respond(answer(envelope, ['Code'], 'Release notes'))) - .toEqual({ accepted: false, reason: 'bad-response' }) - expect(await api.respond(answer(envelope, [], 'Release notes'))) - .toEqual({ accepted: true }) - await expect(asked).resolves.toEqual({ - answers: [{ id: 'target', selected: [], custom: 'Release notes' }], - }) - abort.abort() - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts new file mode 100644 index 0000000000..e0ff471a57 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts @@ -0,0 +1,77 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { ApiSessionNotFound } from '@deepseek-ai/dsh-api-session-controller' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-skill' +import { describe, expect, it, vi } from 'vitest' +import { createApiProxy } from '../src/api-proxy.ts' +import { RpcId } from '../src/api/rpc.ts' + +describe('skill catalog Session inspection', () => { + it('reads a detached Session without resuming its Agent', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = SessionId('cold-skills') + const resolveAgent = vi.fn() + const inspect = vi.fn(() => Promise.resolve({ + meta: { version: 0 as const, id: sessionId, createdAt: 1, cwd: '/cold/project' }, + events: [], + })) + ctx.provide('sessionController', { inspect, resolveAgent } as never) + const list = vi.fn(() => Promise.resolve([{ + name: 'review', + description: 'Review the current change.', + invocation: { modelInvocable: true, userInvocable: true }, + }])) + ctx.provide('skills', { list } as never) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + }) + + const response = await api.skills.list({ rpcId: RpcId('cold-skills'), payload: { sessionId } }) + + expect(response.result).toEqual({ + ok: true, + value: { + skills: [{ + name: 'review', + description: 'Review the current change.', + modelInvocable: true, + }], + }, + }) + expect(inspect).toHaveBeenCalledWith(sessionId) + expect(resolveAgent).not.toHaveBeenCalled() + expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) + }) + + it('preserves missing and failed cold inspection as distinct API errors', async () => { + const sessionId = SessionId('missing-skills') + for (const fixture of [ + { + error: new ApiSessionNotFound('session "missing-skills" not found'), + code: 'session-not-found', + }, + { error: new Error('storage offline'), code: 'internal' }, + ] as const) { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + ctx.provide('sessionController', { + inspect: () => Promise.reject(fixture.error), + resolveAgent: vi.fn(), + } as never) + ctx.provide('skills', { list: vi.fn() } as never) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + }) + + const response = await api.skills.list({ rpcId: RpcId(fixture.code), payload: { sessionId } }) + + expect(response.result).toMatchObject({ ok: false, error: { code: fixture.code } }) + } + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index a11e2a0416..bbdddd878d 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import { SubagentError } from '@deepseek-ai/dsh-subagent' import { RpcId } from '../src/api/rpc.ts' import type { RpcRequest } from '../src/api/rpc.ts' @@ -21,13 +21,6 @@ function bench(options: { followupError?: Error interruptError?: Error listError?: Error - /** Persistence forgets the child entirely (the vanished-mid-read race). */ - storedChild?: false - /** Attach the child to the live session store instead of persistence only. */ - liveChild?: true - /** Every registered projection unit throws on this child's payloads. */ - projectionsThrow?: true - historyParent?: SessionId } = {}) { const parent = { id: PARENT } const child = options.childStatus === undefined @@ -63,49 +56,13 @@ function bench(options: { ) => { if (options.interruptError !== undefined) throw options.interruptError }) - const childHeader = { - version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT, - } satisfies SessionHeader - const childEvents = [ - { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } }, - ] as unknown as SessionEvent[] - const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents })) - const liveBlock = { values: {}, asOfSeq: 3 } - const coldBlock = { values: {}, asOfSeq: 0 } - const snapshot = vi.fn(() => { - if (options.projectionsThrow === true) throw new Error('hostile unit') - return liveBlock - }) - const restore = vi.fn(() => { - if (options.projectionsThrow === true) throw new Error('hostile unit') - return { snapshot: coldBlock } - }) const ctx = new Context() ctx.provide('agents', { get: getAgent }) ctx.provide('subagents', { listChildren, followup, interrupt }) - ctx.provide('sessions', { - get: (id: SessionId) => options.liveChild === true && id === CHILD - ? { id: CHILD, header: childHeader, events: childEvents } - : undefined, - }) - ctx.provide('sessionPersistence', { - list: () => Promise.resolve(options.storedChild === false ? [] : [childHeader]), - inspect, - locate: () => undefined, - }) - // The gateway's own projection push feed subscribes at construction; the - // no-op disposer keeps that feed quiet while these tests pin history reads. - ctx.provide('sessionProjections', { - snapshot, - restore, - onChanged: () => () => {}, - register: () => () => {}, - }) - ctx.provide('userQuestions', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) - return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent } + return { api, getAgent, listChildren, followup, interrupt, parent } } describe('subagent gateway', () => { @@ -150,90 +107,7 @@ describe('subagent gateway', () => { .toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } }) }) - it('reads a healthy direct child without looking up or activating any Agent', async () => { - const { api, getAgent, inspect, restore } = bench() - const response = await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10, - })) - expect(response.result).toMatchObject({ - ok: true, - value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, - }) - expect(inspect).toHaveBeenCalledWith(CHILD) - expect(restore).toHaveBeenCalledTimes(1) - expect(getAgent).not.toHaveBeenCalled() - }) - - it('serves a live child from the in-memory snapshot and the watermark projections', async () => { - const { api, inspect, snapshot, restore } = bench({ liveChild: true }) - const response = await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - })) - expect(response.result).toMatchObject({ - ok: true, - value: { hasMore: false, projections: { asOfSeq: 3 } }, - }) - expect(snapshot).toHaveBeenCalledTimes(1) - expect(restore).not.toHaveBeenCalled() - expect(inspect).not.toHaveBeenCalled() - }) - - it('serves the page without projections when a hostile unit breaks the fold', async () => { - const cold = bench({ projectionsThrow: true }) - const coldResponse = await cold.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - })) - expect(coldResponse.result).toMatchObject({ - ok: true, - value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, - }) - if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false) - - const live = bench({ projectionsThrow: true, liveChild: true }) - const liveResponse = await live.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - })) - expect(liveResponse.result).toMatchObject({ - ok: true, - value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, - }) - if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false) - expect(live.snapshot).toHaveBeenCalledTimes(1) - }) - - it('reads one-shot history and rejects an address with the wrong mode', async () => { - const oneShot = { - kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch', - activity: 'inactive', hasChildren: false, - } - const { api, inspect } = bench({ entries: [oneShot] }) - expect((await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot', - }))).result).toMatchObject({ ok: true }) - expect((await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - }))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } }) - expect(inspect).toHaveBeenCalledTimes(1) - }) - - it('rejects a diagnostic address before reading history', async () => { - const { api, inspect } = bench({ entries: [ - { kind: 'diagnostic', id: CHILD, reason: 'unsupported' }, - ] }) - const response = await api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - })) - expect(response.result).toMatchObject({ - ok: false, - error: { - code: 'subagent-catalog-diagnostic', - details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' }, - }, - }) - expect(inspect).not.toHaveBeenCalled() - }) - - it('maps the missing projections capability to one wire face on list, history, and prompt', async () => { + it('maps the missing projections capability to one wire face on list and prompt', async () => { const listError = () => new SubagentError( 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', @@ -247,12 +121,6 @@ describe('subagent gateway', () => { expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result) .toMatchObject({ ok: false, error: expected }) - const history = bench({ listError: listError() }) - expect((await history.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - }))).result).toMatchObject({ ok: false, error: expected }) - expect(history.inspect).not.toHaveBeenCalled() - const prompt = bench({ listError: listError() }) expect((await prompt.api.subagents.prompt(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], @@ -332,19 +200,7 @@ describe('subagent gateway', () => { }) }) - it('maps history disappearance and hides unexpected backend details', async () => { - const disappeared = bench({ storedChild: false }) - expect((await disappeared.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - }))).result).toMatchObject({ - ok: false, - error: { - code: 'subagent-not-found', - message: 'subagent disappeared during history read', - details: { parentSessionId: PARENT, childSessionId: CHILD }, - }, - }) - + it('hides unexpected backend details', async () => { const catalog = bench({ listError: new Error('secret descriptor') }) expect((await catalog.api.subagents.list(request({ parentSessionId: PARENT, @@ -363,18 +219,17 @@ describe('subagent gateway', () => { }) it('interrupts through the core primitive alone while the parent Agent is offline', async () => { - const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false }) + const { api, interrupt, getAgent, listChildren } = bench({ parentLive: false }) const response = await api.subagents.interrupt(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, })) expect(response.rpcId).toBe('subagent-rpc') expect(response.result).toEqual({ ok: true, value: { accepted: true } }) expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT }) - // No parent-registry, catalog, or history dependency: this is what keeps a + // No parent-registry or catalog dependency: this is what keeps a // live child interruptible after its parent Agent went offline. expect(getAgent).not.toHaveBeenCalled() expect(listChildren).not.toHaveBeenCalled() - expect(inspect).not.toHaveBeenCalled() }) it('maps interrupt authorization rejection without touching other services', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts deleted file mode 100644 index efbb16d682..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ /dev/null @@ -1,571 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' -import Storage from '@deepseek-ai/dsh-storage' -import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' -import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' -import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' -import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' -import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' -import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' - -let nextRpc = 1 - -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload } -} - -function expectOk(response: RpcResponse): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -async function nextHostFrame( - stream: AsyncIterator>, -): Promise> { - const next = await stream.next() - if (next.done === true) throw new Error('Host stream ended before the expected increment') - return next.value -} - -function stubAgent(session: Session): Agent { - return { - id: session.id, - options: {}, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx: new Context(), - send: () => {}, - followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject: () => {}, - cancel() {}, - runMaintenance: job => job(new AbortController().signal), - whenIdle: () => Promise.resolve(), - } -} - -/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ -async function harness( - root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), - picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, - extras: { - openPath?: (path: string, signal: AbortSignal) => Promise - canOpenPath?: () => boolean - } = {}, -) { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserQuestionService) - await ctx.plugin(Storage) - ctx.storage.backend.register('memory', new MemoryStorageBackend()) - const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) - ctx.storage.mount('domain', storageDomain) - ctx.provide('storageDomain', storageDomain) - ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) - await ctx.plugin(WorkspaceRegistry) - - const factory: AgentFactory = { - async createAgent(_ownerCtx, options) { - const session = ctx.sessions.create( - options.sessionId, - options.meta === undefined ? {} : { meta: options.meta }, - ) - const agent = stubAgent(session) - const unregister = ctx.agents.register(agent) - return { - agent, - dispose: () => { - unregister() - return Promise.resolve() - }, - } - }, - async resume() { - throw new Error('test harness has no persisted sessions') - }, - } - ctx.agents.setFactory(factory) - // Structural picker fake: the gateway only reads capability(); a stable - // object per harness mirrors the seam's stability contract. - ctx.provide('directoryPicker', { capability: () => picker } as never) - const api = createApiProxy(ctx, { - defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), - cwd: root, - ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, - ...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath }, - }) - return { api, ctx, storageDomain, root } -} - -/** Stage one directory under the harness root for path adoption. */ -function stageDir(root: string, name: string): string { - const path = join(root, name) - mkdirSync(path) - return path -} - -describe('host.pickDirectory', () => { - it('returns a selected path or explicit cancellation from the native capability', async () => { - const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' }) - expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) - .toEqual({ ok: true, value: { path: '/tmp/project' } }) - - const cancelled = await harness(undefined, { kind: 'native', pick: async () => null }) - expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) - .toEqual({ ok: true, value: { path: null } }) - }) - - it('propagates abort into the native capability as a cancelled RPC error', async () => { - const { api } = await harness(undefined, { - kind: 'native', - pick: signal => new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) - }), - }) - const abort = new AbortController() - const pending = api.host.pickDirectory(request({}), abort.signal) - abort.abort() - expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) - }) - - it('folds a non-abort native-chooser failure into an internal error', async () => { - const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } }) - const response = await api.host.pickDirectory(request({}), new AbortController().signal) - expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) - }) - - it('refuses the native RPC under a browse composition', async () => { - const { api } = await harness(undefined, BROWSE_STUB) - const response = await api.host.pickDirectory(request({}), new AbortController().signal) - expect(response.result).toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } }, - }) - }) -}) - -/** Canned browse capability: one listing, one created path, typed failures on demand. */ -const BROWSE_STUB: DirectoryPickerCapability = { - kind: 'browse', - list: async (path) => { - if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') - const target = path ?? '/home/user' - return { - path: target, - home: '/home/user', - crumbs: [{ name: '/', path: '/', hidden: false }], - entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], - truncated: false, - } - }, - createDirectory: async (path, name) => { - if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') - if (name === 'unwritable') throw new Error('disk detached') - return `${path}/${name}` - }, -} - -describe('host.listDirectory / host.createDirectory', () => { - it('serves listings and creation through the browse capability, defaulting to home', async () => { - const { api } = await harness(undefined, BROWSE_STUB) - const home = await api.host.listDirectory(request({}), new AbortController().signal) - expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) - const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal) - expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) - const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) - expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) - }) - - it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => { - const { api } = await harness(undefined, BROWSE_STUB) - expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({ - ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } }, - }) - expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({ - ok: false, error: { code: 'directory-exists' }, - }) - expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({ - ok: false, error: { code: 'internal' }, - }) - }) - - it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => { - const { api } = await harness(undefined, { - kind: 'browse', - list: (_path, signal) => new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) - }), - createDirectory: async () => '/never', - }) - const abort = new AbortController() - const pending = api.host.listDirectory(request({}), abort.signal) - abort.abort() - expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) - }) - - it('refuses the browse RPCs under a native composition', async () => { - const { api } = await harness() - expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({ - ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, - }) - expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({ - ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, - }) - }) -}) - -describe('host.openPath', () => { - it('describes whether this deployment can reach a user-visible native desktop', async () => { - const visible = await harness(undefined, undefined, { canOpenPath: () => true }) - const headless = await harness(undefined, undefined, { canOpenPath: () => false }) - expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) - expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) - expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) - }) - - it('opens through the injected native boundary', async () => { - const opened: string[] = [] - const { api } = await harness(undefined, undefined, { - openPath: async (path) => { opened.push(path) }, - }) - expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result) - .toEqual({ ok: true, value: { opened: true } }) - expect(opened).toEqual(['/tmp/a.txt']) - }) - - it('propagates abort into the native boundary as a cancelled RPC error', async () => { - const { api } = await harness(undefined, undefined, { - openPath: (_path, signal) => new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) - }), - }) - const abort = new AbortController() - const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal) - abort.abort() - expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) - }) -}) - -describe('workspace.create', () => { - it('serializes concurrent creates of one path into a single registration', async () => { - const { api, root } = await harness() - const target = stageDir(root, 'alpha') - const responses = await Promise.all([ - api.workspace.create(request({ path: target })), - api.workspace.create(request({ path: target })), - ]) - const values = responses.map(response => expectOk(response)) - const created = values.find(value => value.created) - const resolved = values.find(value => !value.created) - - expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } }) - expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId) - expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1) - }) - - it('adopts only existing directories', async () => { - const { api, root } = await harness() - const existing = stageDir(root, 'existing') - const first = expectOk(await api.workspace.create(request({ path: existing }))) - const repeated = expectOk(await api.workspace.create(request({ path: existing }))) - expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) - expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } }) - - expectOk(await api.workspace.rename(request({ - workspaceId: first.workspace.workspaceId, - title: 'renamed-existing', - }))) - const reopened = expectOk(await api.workspace.create(request({ path: existing }))) - expect(reopened.workspace.title).toBe('renamed-existing') - - const missing = join(root, 'missing') - const missingResult = await api.workspace.create(request({ path: missing })) - expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) - expect(existsSync(missing)).toBe(false) - }) - - it('adopts different paths that derive the same Workspace title', async () => { - const { api, root } = await harness() - const first = join(root, 'one', 'project') - const second = join(root, 'two', 'project') - mkdirSync(first, { recursive: true }) - mkdirSync(second, { recursive: true }) - const firstResult = expectOk(await api.workspace.create(request({ path: first }))) - const secondResult = expectOk(await api.workspace.create(request({ path: second }))) - expect(firstResult).toMatchObject({ - created: true, - workspace: { path: first, title: 'project' }, - }) - expect(secondResult).toMatchObject({ - created: true, - workspace: { path: second, title: 'project' }, - }) - expect(secondResult.workspace.workspaceId).not.toBe(firstResult.workspace.workspaceId) - expect(expectOk(await api.workspace.list(request({}))).items.map(workspace => workspace.path)) - .toEqual([second, first]) - }) -}) - -describe('workspace.insertBefore', () => { - it('commits the complete order, streams one order frame, and maps unknown ids', async () => { - const { api, ctx, root } = await harness() - const first = expectOk(await api.workspace.create(request({ path: stageDir(root, 'first') }))).workspace - const second = expectOk(await api.workspace.create(request({ path: stageDir(root, 'second') }))).workspace - const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace - - const abort = new AbortController() - const listWorkspaces = vi.spyOn(ctx.workspaceRegistry, 'list') - const stream: AsyncIterator> = - api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() - expect(listWorkspaces).toHaveBeenCalledTimes(1) - const changed = nextHostFrame(stream) - const reordered = expectOk(await api.workspace.insertBefore(request({ - workspaceId: first.workspaceId, - beforeWorkspaceId: second.workspaceId, - }))) - expect(reordered.workspaceIds).toEqual([third.workspaceId, first.workspaceId, second.workspaceId]) - expect(await changed).toMatchObject({ - payload: { - type: 'host/workspace-order-changed', - workspaceIds: [third.workspaceId, first.workspaceId, second.workspaceId], - }, - }) - expect(expectOk(await api.workspace.list(request({}))).items.map(item => item.workspaceId)) - .toEqual(reordered.workspaceIds) - - const missingSource = await api.workspace.insertBefore(request({ - workspaceId: 'missing' as WorkspaceId, - })) - expect(missingSource.result).toMatchObject({ - ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing' } }, - }) - const missingAnchor = await api.workspace.insertBefore(request({ - workspaceId: first.workspaceId, - beforeWorkspaceId: 'missing-anchor' as WorkspaceId, - })) - expect(missingAnchor.result).toMatchObject({ - ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing-anchor' } }, - }) - abort.abort() - }) -}) - -describe('session creation and Workspace membership', () => { - it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { - const { api, ctx, root } = await harness() - const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace - const sessionId = SessionId('session-workspace-preallocated') - - expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) - expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) - expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) - expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1) - - const ungrouped = SessionId('session-cwd-only') - expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped }))) - expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) - expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped) - - const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId })) - expect(conflict.result).toMatchObject({ - ok: false, - error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } }, - }) - const missing = await api.sessions.create(request({ - workspaceId: 'missing-workspace' as WorkspaceId, - sessionId: SessionId('session-missing-workspace'), - })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) - }) - - it('retains a published session when attachment fails and repairs it on retry', async () => { - const { api, ctx, root } = await harness() - const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace - const workspace = ctx.workspaceRegistry.list()[0] - if (workspace === undefined) throw new Error('workspace missing from registry') - vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure')) - const sessionId = SessionId('session-attach-retry') - - const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })) - expect(failed.result).toMatchObject({ - ok: false, - error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } }, - }) - expect(ctx.agents.get(sessionId)).toBeDefined() - - expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))) - expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) - }) -}) - -describe('Host Workspace increments', () => { - it('projects subagent origin in attached summaries and creation increments', async () => { - const { api, ctx } = await harness() - const abort = new AbortController() - const stream: AsyncIterator> = - api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() - const pending = nextHostFrame(stream) - const childId = SessionId('session-subagent-child') - - ctx.sessions.create(childId, { - meta: { - cwd: '/tmp', - parentSession: SessionId('session-parent'), - origin: 'subagent', - }, - }) - - expect(await pending).toMatchObject({ - payload: { - type: 'host/session-added', - sessionId: childId, - parentSessionId: 'session-parent', - origin: 'subagent', - }, - }) - expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual( - expect.objectContaining({ sessionId: childId, origin: 'subagent' }), - ) - abort.abort() - }) - - it('streams committed Workspace and Session increments after empty baselines', async () => { - const { api, root } = await harness() - expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) - expect(expectOk(await api.sessions.list(request({}))).items).toEqual([]) - - const abort = new AbortController() - const stream: AsyncIterator> = - api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() - const workspaceIncrement = nextHostFrame(stream) - const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace - expect(await workspaceIncrement).toMatchObject({ - payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } }, - }) - - const sessionId = SessionId('session-streamed-workspace') - const pending = nextHostFrame(stream) - expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) - const increments: HostFrame[] = [] - increments.push((await pending).payload) - while (increments.length < 2) { - const next = await stream.next() - if (next.done === true) throw new Error('Host stream ended before both increments') - increments.push(next.value.payload) - } - expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({ - // A just-created session has no events: the frame constantly carries blank:true. - type: 'host/session-added', sessionId, blank: true, cwd: workspace.path, - }) - const workspaceChanged = increments.find( - (increment): increment is Extract => - increment.type === 'host/workspace-changed', - ) - expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId]) - abort.abort() - }) - - it('does not publish a Workspace whose registry-order commit fails', async () => { - const { api, storageDomain, root } = await harness() - const domain = storageDomain.get('workspace') - if (domain === undefined) throw new Error('workspace domain is not open') - vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure')) - const abort = new AbortController() - const stream: AsyncIterator> = - api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() - const next = stream.next() - - const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') })) - expect(failed.result.ok).toBe(false) - expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) - abort.abort() - expect(await next).toMatchObject({ done: true }) - }) - - it('deletes the registration, keeps its session and folder, and streams one removal', async () => { - const { api, ctx, root } = await harness() - const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace - const sessionId = SessionId('session-kept-after-workspace-delete') - expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) - - const abort = new AbortController() - const stream: AsyncIterator> = - api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() - const removed = nextHostFrame(stream) - expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))) - expect(await removed).toMatchObject({ - payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId }, - }) - expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) - expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) - expect(ctx.agents.get(sessionId)).toBeDefined() - expect(existsSync(workspace.path)).toBe(true) - - const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId })) - expect(missing.result).toMatchObject({ - ok: false, - error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } }, - }) - - const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace - expect(reregistered.workspaceId).not.toBe(workspace.workspaceId) - expect(reregistered.path).toBe(workspace.path) - expect(reregistered.sessionIds).toEqual([]) - expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) - abort.abort() - }) - - it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { - const { api, root } = await harness() - const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace - const sessionId = SessionId('session-to-archive') - expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) - expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) - - const abort = new AbortController() - const stream: AsyncIterator> = - api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() - const changed = nextHostFrame(stream) - expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) - .toEqual([sessionId]) - expect(await changed).toMatchObject({ - payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] }, - }) - - // Accounting and the session itself are untouched; list re-baselines the set. - const listed = expectOk(await api.workspace.list(request({}))) - expect(listed.archivedSessionIds).toEqual([sessionId]) - expect(listed.items[0]?.sessionIds).toEqual([sessionId]) - expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) - - // The idempotent repeat emits no second frame: the next observed frame is - // the workspace-changed of a later attach, not another archive snapshot. - const after = nextHostFrame(stream) - expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) - .toEqual([sessionId]) - const otherSession = SessionId('session-after-archive') - expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession }))) - expect((await after).payload.type).not.toBe('host/archived-sessions-changed') - - const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') })) - expect(missing.result).toMatchObject({ - ok: false, - error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } }, - }) - abort.abort() - }) -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index de9d4ddab0..648af08e3b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -1,13 +1,13 @@ /** * Wire-protocol coverage over the isomorphic point: InProcessApiClient → * toFetchHandler(scripted impl) runs the real envelope wrap/unwrap, zod - * two-level parse, rpcId discipline, and SSE framing with no network and no - * browser. Each case scripts its own minimal ApiProxy. + * two-level parse, and rpcId discipline with no network or browser. Each case + * scripts its own minimal ApiProxy. */ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { ApiProxy, GoalRef, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' +import type { ApiProxy, GoalRef, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' const sid = (id: string): SessionId => id as SessionId @@ -18,54 +18,20 @@ function ok(request: RpcRequest, value: T): Promise> /** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */ function scriptedApi(overrides: { - sessions?: Partial subagents?: Partial host?: Partial skills?: Partial agentPresets?: Partial - events?: Partial goals?: Partial settings?: Partial credentials?: Partial llm?: Partial - respond?: ApiProxy['respond'] } = {}): ApiProxy { - async function *empty(): AsyncGenerator> { /* no frames */ } const err = (r: RpcRequest): Promise> => Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } }) return { - sessions: { - list: r => ok(r, { items: [] }), - search: r => ok(r, { items: [], hasMore: false }), - create: r => ok(r, { sessionId: sid('s-new') }), - history: r => ok(r, { - events: [], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - models: r => ok(r, { - current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - routable: true, - groups: [], - failures: [], - }), - selectModel: r => ok(r, { - selected: { provider: r.payload.provider, model: r.payload.model }, - }), - rename: r => ok(r, { title: 'renamed', seq: 0 }), - fork: r => ok(r, { sessionId: sid('s-fork') }), - prompt: r => ok(r, { accepted: true as const }), - attachment: r => ok(r, { - attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, - data: 'AA==', - }), - updateQueue: r => ok(r, { accepted: true as const }), - cancel: r => ok(r, { accepted: true as const }), - ...overrides.sessions, - }, subagents: { list: r => ok(r, { entries: [], parentAvailable: false }), - history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { messageId: 'message-1' as never }), interrupt: r => ok(r, { accepted: true as const }), ...overrides.subagents, @@ -80,15 +46,6 @@ function scriptedApi(overrides: { openPath: r => ok(r, { opened: true as const }), ...overrides.host, }, - workspace: { - list: r => ok(r, { items: [], archivedSessionIds: [] }), - create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), - rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), - delete: r => ok(r, { deleted: true as const }), - insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }), - insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), - archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), - }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, agentPresets: { list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), @@ -128,8 +85,6 @@ function scriptedApi(overrides: { discoverModels: err, ...overrides.llm, }, - events: { mux: () => empty(), host: () => empty(), ...overrides.events }, - respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), downloads: { sessionLog: async () => new Response('stub', { status: 404 }) }, } } @@ -149,94 +104,20 @@ function recorderInto(seen: { method: string; payload: unknown }[]) { describe('unary round trip', () => { it('carries payload out and value back through the full wire form', async () => { - let seen: RpcRequest<{ cursor?: string }> | undefined + let seen: RpcRequest<{}> | undefined const api = scriptedApi({ - sessions: { - list: (r) => { - seen = r - return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false, blank: false }] }) + host: { + describe: (request) => { + seen = request + return ok(request, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true }) }, }, }) - const response = await client(api).sessions.list({ cursor: 'c1' }) - // Impl received the narrow form with a minted id; client returned the same id and value. - expect(seen?.payload).toEqual({ cursor: 'c1' }) + const response = await client(api).host.describe({}) + expect(seen?.payload).toEqual({}) expect(seen?.rpcId).toBeTruthy() expect(response.rpcId).toBe(seen?.rpcId) - expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } }) - }) - - it('round-trips a trimmed session search query and its bounded result metadata', async () => { - let seen: RpcRequest<{ query: string }> | undefined - const api = scriptedApi({ - sessions: { - search: (request) => { - seen = request - return ok(request, { - items: [{ sessionId: sid('s1'), snippet: 'matching message text' }], - hasMore: true, - }) - }, - }, - }) - const response = await client(api).sessions.search({ query: ' message text ' }) - expect(seen?.payload).toEqual({ query: 'message text' }) - expect(response.result).toEqual({ - ok: true, - value: { - items: [{ sessionId: 's1', snippet: 'matching message text' }], - hasMore: true, - }, - }) - }) - - it('rejects an overlong session-search snippet at the client value boundary', async () => { - const api = scriptedApi({ - sessions: { - search: request => ok(request, { - items: [{ sessionId: sid('s1'), snippet: '😀'.repeat(241) }], - hasMore: false, - }), - }, - }) - - await expect(client(api).sessions.search({ query: 'message' })) - .rejects.toThrow(/240 Unicode code points/) - }) - - it('routes session fork with its optional cut anchor through the wire', async () => { - let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined - const api = scriptedApi({ - sessions: { - fork: (request) => { - seen = request - return ok(request, { sessionId: sid('s-child') }) - }, - }, - }) - const response = await client(api).sessions.fork({ sessionId: sid('s-parent'), atSeq: 7 }) - expect(seen?.payload).toEqual({ sessionId: 's-parent', atSeq: 7 }) - expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } }) - }) - - it('routes workspace rename, delete, and ordering through the wire', async () => { - const api = scriptedApi() - const c = client(api) - const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) - expect(renamed.result.ok).toBe(true) - const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) - expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) - const deleted = await c.workspace.delete({ workspaceId: 'w1' as never }) - expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) - const workspaceOrder = await c.workspace.insertBefore({ - workspaceId: 'w1' as never, - beforeWorkspaceId: 'w2' as never, - }) - expect(workspaceOrder.result).toEqual({ ok: true, value: { workspaceIds: ['w1'] } }) - const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) - expect(anchored.result.ok).toBe(true) - const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) - expect(appended.result.ok).toBe(true) + expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } }) }) it('routes the agent-preset roster and switch through the wire', async () => { @@ -253,29 +134,27 @@ describe('unary round trip', () => { it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ - sessions: { - cancel: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: sid('sx') } } } }), + host: { + describe: request => Promise.resolve({ + rpcId: request.rpcId, + result: { ok: false, error: { code: 'internal', message: 'nope', details: {} } }, + }), }, }) - const response = await client(api).sessions.cancel({ sessionId: sid('sx') }) - expect(response.result).toEqual({ ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: 'sx' } } }) + const response = await client(api).host.describe({}) + expect(response.result).toEqual({ ok: false, error: { code: 'internal', message: 'nope', details: {} } }) }) it('throws on rpcId echo mismatch', async () => { const api = scriptedApi({ - sessions: { list: () => Promise.resolve({ rpcId: RpcId('forged'), result: { ok: true, value: { items: [] } } }) }, + host: { + describe: () => Promise.resolve({ + rpcId: RpcId('forged'), + result: { ok: true, value: { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true } }, + }), + }, }) - await expect(client(api).sessions.list({})).rejects.toThrow(/rpcId mismatch/) - }) - - it('rejects an invalid payload at the handler as 200 + bad-request with issues', async () => { - const api = scriptedApi() - const response = await client(api).sessions.history({ sessionId: 123 as unknown as SessionId }) - expect(response.result.ok).toBe(false) - if (!response.result.ok) { - expect(response.result.error.code).toBe('bad-request') - expect((response.result.error.details as { issues: unknown[] }).issues.length).toBeGreaterThan(0) - } + await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/) }) it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => { @@ -306,8 +185,8 @@ describe('unary round trip', () => { it('rejects a method/path mismatch as bad-request', async () => { const handler = toFetchHandler(scriptedApi()) - const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} } - const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + const body = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} } + const response = await handler.fetch('http://dsh.internal/api/skill.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) expect(response.status).toBe(200) const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } } expect(parsed.result.ok).toBe(false) @@ -318,13 +197,13 @@ describe('unary round trip', () => { it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => { const handler = toFetchHandler(scriptedApi()) // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse. - const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) }) + const noId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) }) expect(noId.status).toBe(200) const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } } expect(noIdParsed.result.ok).toBe(false) expect(noIdParsed.rpcId).toBe('invalid-request') // A string rpcId in the otherwise-bad body is salvaged for correlation. - const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) + const withId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } } expect(withIdParsed.result.ok).toBe(false) expect(withIdParsed.rpcId).toBe('salvage-me') @@ -336,29 +215,31 @@ describe('unary round trip', () => { const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) expect(notFound.status).toBe(404) // Non-JSON body → 400. - const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }) + const badBody = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }) expect(badBody.status).toBe(400) // Impl crash → 500, and through the client that is a throw, not an err result. - const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } }) - await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/) + const crashing = scriptedApi({ host: { describe: () => { throw new Error('impl exploded') } } }) + await expect(client(crashing).host.describe({})).rejects.toThrow(/transport failure .*500/) }) it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => { - const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] })) - const handler = toFetchHandler(scriptedApi({ sessions: { list } })) - const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} }) + const describe = vi.fn((request: RpcRequest<{}>) => ok(request, { + version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, + })) + const handler = toFetchHandler(scriptedApi({ host: { describe } })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} }) // A "simple" browser POST (text/plain — sent with no CORS preflight) is // refused at the carrier before the impl runs. - const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body }) + const plain = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'text/plain' }, body }) expect(plain.status).toBe(415) // A string body with no explicit header defaults to text/plain — same fence. - const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body }) + const unlabelled = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', body }) expect(unlabelled.status).toBe(415) - expect(list).not.toHaveBeenCalled() + expect(describe).not.toHaveBeenCalled() // Media-type parameters pass: the fence checks the type, not the exact string. - const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body }) + const charset = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body }) expect(charset.status).toBe(200) - expect(list).toHaveBeenCalledTimes(1) + expect(describe).toHaveBeenCalledTimes(1) }) it('rejects when the transport never resolves within timeoutMs', async () => { @@ -368,7 +249,7 @@ describe('unary round trip', () => { init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) }) }), }, 25) - await expect(never.sessions.list({})).rejects.toThrow() + await expect(never.host.describe({})).rejects.toThrow() }) it('aborts a unary call through the caller-supplied external signal', async () => { @@ -376,7 +257,7 @@ describe('unary round trip', () => { // works even when the transport ignores the signal entirely (hung impl). const gate = new AbortController() const hung = new InProcessApiClient({ fetch: () => new Promise(() => {}) }, 60_000) - const call = hung.sessions.list({}, gate.signal) + const call = hung.host.describe({}, gate.signal) gate.abort(new Error('externally aborted')) await expect(call).rejects.toThrow(/externally aborted/) }) @@ -391,14 +272,14 @@ describe('unary round trip', () => { }, 60_000) const gate = new AbortController() gate.abort('gone before start') - await expect(c.sessions.list({}, gate.signal)).rejects.toThrow('gone before start') + await expect(c.host.describe({}, gate.signal)).rejects.toThrow('gone before start') expect(touched).toBe(false) }) it('maps a non-Error, non-string abort reason to the default AbortError message', async () => { const gate = new AbortController() const hung = new InProcessApiClient({ fetch: () => new Promise(() => {}) }, 60_000) - const call = hung.sessions.list({}, gate.signal) + const call = hung.host.describe({}, gate.signal) gate.abort(42) await expect(call).rejects.toThrow('This operation was aborted') }) @@ -417,179 +298,9 @@ describe('unary round trip', () => { it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => { // Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject. const api = scriptedApi({ - sessions: { list: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: true, value: { items: 'not-an-array' } } }) as never }, + host: { describe: request => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { version: 1 } } }) as never }, }) - await expect(client(api).sessions.list({})).rejects.toThrow() - }) -}) - -describe('workspace domain round trip', () => { - it('routes both workspace methods through their handler rows and value schemas', async () => { - const c = client(scriptedApi()) - const list = await c.workspace.list({}) - expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } }) - const created = await c.workspace.create({ path: '/t' }) - expect(created.result.ok).toBe(true) - if (created.result.ok) expect(created.result.value.created).toBe(true) - const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never }) - expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) - }) - - it('rejects a pathless create payload at the handler schema', async () => { - const response = await client(scriptedApi()).workspace.create({} as never) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') - }) -}) - -describe('SSE stream path', () => { - it('yields frames in order and skips the comment preamble', async () => { - const frames: MuxFrame[] = [ - { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 3 }, - { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } }, - ] - const api = scriptedApi({ - events: { - async *mux(request) { - let n = 0 - for (const frame of frames) yield { rpcId: RpcId(`push-${n++}-${request.rpcId}`), payload: frame } - }, - }, - }) - const seen: MuxFrame[] = [] - for await (const envelope of client(api).events.mux({}, new AbortController().signal)) { - seen.push(envelope.payload) - } - expect(seen).toEqual(frames) - }) - - it('reassembles frames across arbitrary chunk boundaries', async () => { - // Two SSE frames split so one frame spans chunks and one chunk carries parts of both. - const f1 = { type: 'server-request', rpcId: 'a', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } } - const f2 = { type: 'server-request', rpcId: 'b', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's2', lastSeq: 2 } } - const wire = `: connected\n\ndata: ${JSON.stringify(f1)}\n\ndata: ${JSON.stringify(f2)}\n\n` - const cuts = [5, 40, wire.indexOf('data: ', 40) + 3] - const encoder = new TextEncoder() - const doFetch = (): Promise => Promise.resolve(new Response(new ReadableStream({ - start(controller) { - let prev = 0 - for (const cut of [...cuts, wire.length]) { - controller.enqueue(encoder.encode(wire.slice(prev, cut))) - prev = cut - } - controller.close() - }, - }), { status: 200 })) - const chopped = new InProcessApiClient({ fetch: doFetch }) - const seen: string[] = [] - for await (const envelope of chopped.events.mux({}, new AbortController().signal)) { - seen.push((envelope.payload as { sessionId: string }).sessionId) - expect(envelope.rpcId).toBe(seen.length === 1 ? 'a' : 'b') - } - expect(seen).toEqual(['s1', 's2']) - }) - - it('emits a stream/error frame then closes when the impl throws mid-stream', async () => { - const api = scriptedApi({ - events: { - async *host(request): AsyncGenerator> { - yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1'), blank: true } } - throw new Error('impl died mid-stream') - }, - }, - }) - const seen: HostFrame[] = [] - for await (const envelope of client(api).events.host({}, new AbortController().signal)) { - seen.push(envelope.payload) - } - expect(seen.map(f => f.type)).toEqual(['host/session-added', 'stream/error']) - const last = seen.at(-1) - if (last?.type === 'stream/error') expect(last.error.message).toMatch(/impl died mid-stream/) - }) - - it('drops a malformed SSE frame and keeps the stream alive (S→C two-level parse)', async () => { - const good = { type: 'server-request', rpcId: 'g1', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } } - const badEnvelope = { type: 'server-response', rpcId: 'x' } // wrong quadrant for a stream - const badFrame = { type: 'server-request', rpcId: 'b1', method: 'nope', payload: { type: 'no/such-frame' } } - const wire = [ - 'data: {oops', // not JSON - `data: ${JSON.stringify(badEnvelope)}`, - `data: ${JSON.stringify(badFrame)}`, - `data: ${JSON.stringify(good)}`, - ].map(l => `${l}\n\n`).join('') - const doFetch = (): Promise => Promise.resolve(new Response(new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(wire)) - controller.close() - }, - }), { status: 200 })) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - const seen: MuxFrame[] = [] - for await (const envelope of new InProcessApiClient({ fetch: doFetch }).events.mux({}, new AbortController().signal)) { - seen.push(envelope.payload) - } - // The three corrupt frames are reported and skipped; the good one still arrives. - expect(seen).toEqual([{ type: 'session/subscribed', sessionId: 's1', lastSeq: 1 }]) - expect(errorSpy.mock.calls.length).toBe(3) - } finally { - errorSpy.mockRestore() - } - }) - - it('fires onOpen once headers are in, before the first frame, and not on transport failure', async () => { - const api = scriptedApi({ - events: { - async *mux(request): AsyncGenerator> { - yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 0 } } - }, - }, - }) - const order: string[] = [] - const iterator = client(api).events.mux({}, new AbortController().signal, () => order.push('open')) - expect(order).toEqual([]) // lazy generator: no fetch (and no onOpen) before iteration - for await (const _ of iterator) order.push('frame') - expect(order).toEqual(['open', 'frame']) - - // Transport failure path: onOpen must not fire. - const failing = new InProcessApiClient({ fetch: () => Promise.resolve(new Response('down', { status: 503 })) }) - const failOrder: string[] = [] - await expect((async () => { - for await (const _ of failing.events.mux({}, new AbortController().signal, () => failOrder.push('open'))) { /* unreachable */ } - })()).rejects.toThrow(/transport failure/) - expect(failOrder).toEqual([]) - }) - - it('stops consuming when the caller aborts', async () => { - let implSawAbort = false - const api = scriptedApi({ - events: { - async *mux(_request, signal): AsyncGenerator> { - try { - let n = 0 - while (true) { - yield { rpcId: RpcId(`p${n}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: n++ } } - await new Promise(resolve => setTimeout(resolve, 5)) - if (signal.aborted) return - } - } finally { - implSawAbort = true - } - }, - }, - }) - const abort = new AbortController() - let count = 0 - // In-process abort ends the stream (impl returns on signal.aborted); over a real - // network fetch the same abort surfaces as a rejection — both stop the loop. - await (async () => { - for await (const _ of client(api).events.mux({}, abort.signal)) { - if (++count === 2) abort.abort() - } - })().catch(() => undefined) - expect(count).toBe(2) - // Generator teardown may lag the abort by a microtask; poll briefly. - await vi.waitFor(() => { expect(implSawAbort).toBe(true) }) + await expect(client(api).host.describe({})).rejects.toThrow() }) }) @@ -650,36 +361,13 @@ describe('goals unary surface', () => { }) }) -describe('respond path', () => { - it('round-trips a client-response to a receipt', async () => { - const seen: unknown[] = [] - const api = scriptedApi({ - respond: (message) => { - seen.push(message) - return Promise.resolve({ accepted: true as const }) - }, - }) - const receipt = await client(api).respond({ type: 'client-response', rpcId: RpcId('req-1'), result: { ok: true, value: { behavior: 'allow' } } }) - expect(receipt).toEqual({ accepted: true }) - expect(seen).toEqual([{ type: 'client-response', rpcId: 'req-1', result: { ok: true, value: { behavior: 'allow' } } }]) - }) - - it('returns bad-response for a malformed client-response without reaching the impl', async () => { - const respond = vi.fn() - const handler = toFetchHandler(scriptedApi({ respond })) - const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) }) - expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' }) - expect(respond).not.toHaveBeenCalled() - }) -}) - describe('envelope tap', () => { it('delivers one microtask batch of full forms per unary call', async () => { const api = scriptedApi() const tapped = client(api) const batches: (readonly RpcMessage[])[] = [] tapped.subscribeEnvelopes(batch => batches.push(batch)) - await tapped.sessions.list({}) + await tapped.host.describe({}) await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) }) const all = batches.flat() expect(all.map(m => m.type)).toEqual(['client-request', 'server-response']) @@ -694,7 +382,7 @@ describe('envelope tap', () => { const good: string[] = [] tapped.subscribeEnvelopes(() => { throw new Error('listener bug') }) tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type))) - const response = await tapped.sessions.list({}) + const response = await tapped.host.describe({}) expect(response.result.ok).toBe(true) await vi.waitFor(() => { expect(good).toContain('server-response') }) } finally { @@ -705,11 +393,11 @@ describe('envelope tap', () => { it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => { const api = scriptedApi() const tapped = client(api) - await tapped.sessions.list({}) // no subscribers: must not accumulate + await tapped.host.describe({}) // no subscribers: must not accumulate const batches: (readonly RpcMessage[])[] = [] const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch)) unsubscribe() - await tapped.sessions.list({}) + await tapped.host.describe({}) await new Promise(resolve => setTimeout(resolve, 0)) expect(batches).toEqual([]) }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 77432c55af..a512edd7ce 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,121 +1,16 @@ import { describe, expect, it, vi } from 'vitest' -import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' -import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' -import { RpcId } from '../src/api/rpc.ts' +import type { ApiProxy } from '../src/api/index.ts' +import type { RpcMessage, RpcRequest } from '../src/api/rpc.ts' import { toFetchHandler } from '../src/fetch/handler.ts' import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' -/** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */ -function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFrame[]; crashOn: string }> = {}): ApiProxy { - const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }] - const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }] - async function * stream(frames: F[], signal: AbortSignal): AsyncGenerator> { - for (const payload of frames) { - if (signal.aborted) return - yield { rpcId: RpcId(`frame-${String(frames.indexOf(payload))}`), payload } - } - } +/** Minimal in-memory ApiProxy that echoes rpcIds. */ +function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { return { - sessions: { - async list(request) { - if (overrides.crashOn === 'session.list') throw new Error('impl crashed') - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } - }, - async search(request, signal) { - if (request.payload.query === 'hang') { - if (!signal.aborted) { - await new Promise((resolve) => { - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - } - return { - rpcId: request.rpcId, - result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } }, - } - } - return { - rpcId: request.rpcId, - result: { - ok: true, - value: { items: [{ sessionId: 's1' as never, snippet: 'fixture match' }], hasMore: false }, - }, - } - }, - async create(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } } - }, - async history(request) { - if (request.payload.sessionId === ('with-projections' as never)) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { events: [], hasMore: false, projections: { asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' as const }] } } } }, - } - } - return { - rpcId: request.rpcId, - result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } }, - } - }, - async models(request) { - return { - rpcId: request.rpcId, - result: { - ok: true, - value: { - current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - routable: true, - groups: [], - failures: [], - }, - }, - } - }, - async selectModel(request) { - return { - rpcId: request.rpcId, - result: { - ok: true, - value: { - selected: { - provider: request.payload.provider, - model: request.payload.model, - ...request.payload.reasoningEffort === undefined - ? {} - : { reasoningEffort: request.payload.reasoningEffort }, - }, - }, - }, - } - }, - async rename(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } } - }, - async fork(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-fork' as never } } } - }, - async prompt(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, - async attachment(request) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { attachment: { attachmentId: 'a' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, data: 'AA==' } }, - } - }, - async updateQueue(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, - async cancel(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } - }, - }, subagents: { async list(request) { return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } } }, - async history(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } } - }, async prompt(request, signal) { if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) { if (!signal.aborted) { @@ -139,6 +34,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, host: { async describe(request) { + if (overrides.crashOn === 'host.describe') throw new Error('impl crashed') return { rpcId: request.rpcId, result: { @@ -160,38 +56,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } }, }, - workspace: { - async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [], archivedSessionIds: [] } } } - }, - async create(request) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } }, - } - }, - async rename(request) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, - } - }, - async delete(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } } - }, - async insertBefore(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { workspaceIds: [request.payload.workspaceId] } } } - }, - async insertSessionBefore(request) { - return { - rpcId: request.rpcId, - result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, - } - }, - async archiveSession(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } - }, - }, agentPresets: { list(request: RpcRequest<{}>) { return Promise.resolve({ @@ -282,13 +146,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } } }, }, - events: { - mux: (_request, signal) => stream(muxFrames, signal), - host: (_request, signal) => stream(hostFrames, signal), - }, - async respond(message: ClientResponse): Promise { - return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } - }, downloads: { async sessionLog() { return new Response('stub', { status: 404 }) @@ -301,70 +158,17 @@ function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClie return new InProcessApiClient(toFetchHandler(api), timeoutMs) } -async function collect(stream: AsyncIterable>): Promise[]> { - const out: RpcRequest[] = [] - for await (const envelope of stream) out.push(envelope) - return out -} - describe('unary round trip (handler ⇄ client, no network)', () => { it('carries a success result and echoes the minted rpcId', async () => { - const response = await client().sessions.list({}) - expect(response.result).toEqual({ ok: true, value: { items: [] } }) + const response = await client().host.describe({}) + expect(response.result).toMatchObject({ ok: true, value: { version: 'v', cwd: '/w' } }) expect(response.rpcId).toMatch(/[0-9a-f-]{36}/) }) - it('carries the tail-page projections block through the wire schema (Zod must not strip it)', async () => { - const response = await client().sessions.history({ sessionId: 'with-projections' as never }) - expect(response.result.ok).toBe(true) - if (response.result.ok) { - expect(response.result.value.projections).toEqual( - { asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' }] } }, - ) - } - }) - it('carries a business error as 200 + error result', async () => { - const response = await client().sessions.history({ sessionId: 'missing' as never }) + const response = await client().settings.update({ ns: 'test', patch: {} }) expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') - }) - - it('covers create/prompt/updateQueue/cancel/describe passthrough', async () => { - const c = client() - expect((await c.sessions.search({ query: 'fixture' })).result).toEqual({ - ok: true, - value: { items: [{ sessionId: 's1', snippet: 'fixture match' }], hasMore: false }, - }) - expect((await c.sessions.create({})).result.ok).toBe(true) - expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true) - const selected = await c.sessions.selectModel({ - sessionId: 's' as never, - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - reasoningEffort: 'max', - }) - expect(selected.result).toMatchObject({ - ok: true, - value: { - selected: { - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - reasoningEffort: 'max', - }, - }, - }) - const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' }) - expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } }) - expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) - expect((await c.sessions.attachment({ sessionId: 's' as never, attachmentId: 'a' as never })).result.ok).toBe(true) - expect((await c.sessions.updateQueue({ - sessionId: 's' as never, - itemId: 'item-1' as never, - action: { kind: 'remove' }, - })).result.ok).toBe(true) - expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) - expect((await c.host.describe({})).result.ok).toBe(true) + if (!response.result.ok) expect(response.result.error.code).toBe('settings-rejected') }) it('round-trips every agent-preset method, authoring included', async () => { @@ -465,11 +269,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const c = client() expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result) .toEqual({ ok: true, value: { entries: [], parentAvailable: false } }) - expect((await c.subagents.history({ - parentSessionId: 'parent' as never, - childSessionId: 'child' as never, - mode: 'one-shot', - })).result).toEqual({ ok: true, value: { events: [], hasMore: false } }) expect((await c.subagents.prompt({ parentSessionId: 'parent' as never, childSessionId: 'child' as never, @@ -508,29 +307,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(handlerSignal.aborted).toBe(true) }) - it('propagates the carrier Request signal into session.search', async () => { - const handler = toFetchHandler(fakeApi()) - const controller = new AbortController() - const body = JSON.stringify({ - type: 'client-request', - rpcId: 'r-search-sig', - method: 'session.search', - payload: { query: 'hang' }, - }) - const pending = handler.fetch(new Request( - 'http://x/api/session.search', - { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }, - )) - controller.abort() - const response = await pending - const parsed = await response.json() as { - rpcId: string - result: { error?: { code: string } } - } - expect(parsed.rpcId).toBe('r-search-sig') - expect(parsed.result.error?.code).toBe('cancelled') - }) - it('propagates the carrier Request signal into subagent.prompt', async () => { const handler = toFetchHandler(fakeApi()) const controller = new AbortController() @@ -589,17 +365,17 @@ describe('handler carrier-layer statuses', () => { it('404s unknown paths and non-POST non-stream methods', async () => { expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404) - expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404) + expect((await handler.fetch(new Request('http://x/api/host.describe', { method: 'GET' }))).status).toBe(404) expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) }) it('400s a non-JSON body', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' })) + const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' })) expect(response.status).toBe(400) }) it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) })) + const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) })) expect(response.status).toBe(200) const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } expect(body.rpcId).toBe('invalid-request') @@ -607,125 +383,51 @@ describe('handler carrier-layer statuses', () => { }) it('rejects a method/path mismatch echoing the envelope rpcId', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'host.describe', payload: {} }) + const response = await handler.fetch(new Request('http://x/api/skill.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } } expect(parsed.rpcId).toBe('r-9') expect(parsed.result.error?.message).toContain('does not match path') }) it('rejects an invalid payload with the zod issues attached', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'host.openPath', payload: {} }) + const response = await handler.fetch(new Request('http://x/api/host.openPath', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } } expect(parsed.result.error?.code).toBe('bad-request') expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0) }) it('500s when the impl itself throws', async () => { - const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' })) - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} }) - const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) + const crashing = toFetchHandler(fakeApi({ crashOn: 'host.describe' })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'host.describe', payload: {} }) + const response = await crashing.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) expect(response.status).toBe(500) expect(await response.text()).toContain('impl crashed') }) - it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => { - const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } }) - const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json() - expect(goodReceipt).toEqual({ accepted: true }) - const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} }) - const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json() - expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' }) - }) - it('accepts (url, init) form fetch invocation', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} }) - const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'host.describe', payload: {} }) + const response = await handler.fetch('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) expect(response.status).toBe(200) }) }) -describe('SSE streams through the carrier', () => { - it('yields mux frames as ServerRequest narrow forms and completes', async () => { - const ac = new AbortController() - const frames = await collect(client().events.mux({}, ac.signal)) - expect(frames).toHaveLength(1) - expect(frames[0]?.payload).toMatchObject({ type: 'session/subscribed' }) - expect(frames[0]?.rpcId).toBe('frame-0') - }) - - it('yields host frames', async () => { - const ac = new AbortController() - const frames = await collect(client().events.host({}, ac.signal)) - expect(frames[0]?.payload).toMatchObject({ type: 'host/session-removed' }) - }) - - it('drops frames after the consumer aborts mid-stream', async () => { - const many = Array.from({ length: 50 }, (_, i): MuxFrame => ({ type: 'session/subscribed', sessionId: `s${String(i)}` as never, lastSeq: i })) - const ac = new AbortController() - const received: RpcRequest[] = [] - for await (const envelope of client(fakeApi({ muxFrames: many })).events.mux({}, ac.signal)) { - received.push(envelope) - if (received.length === 2) break // generator return → reader.cancel path - } - expect(received).toHaveLength(2) - }) - - it('swallows a reader.cancel rejection on early exit', async () => { - const encoder = new TextEncoder() - const body = new ReadableStream({ - start(controller) { - const frame = { type: 'server-request', rpcId: 'f0', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's', lastSeq: -1 } } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`)) - // stream intentionally left open: the consumer breaks first - }, - cancel() { - throw new Error('cancel refused') - }, - }) - const c = new InProcessApiClient({ fetch: async () => new Response(body, { headers: { 'content-type': 'text/event-stream' } }) }) - const received: RpcRequest[] = [] - for await (const envelope of c.events.mux({}, new AbortController().signal)) { - received.push(envelope) - break - } - expect(received).toHaveLength(1) - }) - - it('surfaces a mid-stream impl failure as one stream/error frame, then the stream ends', async () => { - const api = fakeApi() - api.events.mux = (_request, _signal) => (async function * (): AsyncGenerator> { - yield { rpcId: RpcId('f0'), payload: { type: 'session/subscribed', sessionId: 's' as never, lastSeq: -1 } } - throw new Error('stream source died') - })() - const frames = await collect(client(api).events.mux({}, new AbortController().signal)) - expect(frames).toHaveLength(2) - expect(frames[1]?.payload).toMatchObject({ type: 'stream/error', error: { code: 'internal' } }) - }) -}) - -describe('client respond and transport failures', () => { - it('passes a client-response through and parses the receipt', async () => { - const receipt = await client().respond({ type: 'client-response', rpcId: RpcId('known'), result: { ok: true, value: null } }) - expect(receipt).toEqual({ accepted: true }) - const late = await client().respond({ type: 'client-response', rpcId: RpcId('late'), result: { ok: true, value: null } }) - expect(late).toEqual({ accepted: false, reason: 'not-pending' }) - }) - - it('throws on non-OK unary and respond and stream transport', async () => { +describe('client transport failures', () => { + it('throws on a non-OK unary transport', async () => { const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) }) - await expect(broken.sessions.list({})).rejects.toThrow('transport failure for /api/session.list: HTTP 503') - await expect(broken.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })) - .rejects.toThrow('transport failure for /api/respond') - await expect(collect(broken.events.mux({}, new AbortController().signal))).rejects.toThrow('transport failure for /api/events.mux') + await expect(broken.host.describe({})).rejects.toThrow('transport failure for /api/host.describe: HTTP 503') }) it('throws on an rpcId echo mismatch', async () => { const lying = new InProcessApiClient({ - fetch: async () => Response.json({ type: 'server-response', rpcId: 'someone-else', result: { ok: true, value: { items: [] } } }), + fetch: async () => Response.json({ + type: 'server-response', + rpcId: 'someone-else', + result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true } }, + }), }) - await expect(lying.sessions.list({})).rejects.toThrow('rpcId mismatch') + await expect(lying.host.describe({})).rejects.toThrow('rpcId mismatch') }) }) @@ -736,7 +438,7 @@ describe('envelope observation', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') }) const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) }) - await c.sessions.list({}) + await c.host.describe({}) await new Promise((resolve) => { setTimeout(resolve, 0) }) // request and response tap in separate microtask windows (the await between // them yields), so both arrive but batch count is timing-defined @@ -752,7 +454,7 @@ describe('envelope observation', () => { const seen: RpcMessage[] = [] const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) }) unsubscribe() - await c.sessions.list({}) + await c.host.describe({}) await new Promise((resolve) => { setTimeout(resolve, 0) }) expect(seen).toHaveLength(0) }) @@ -761,7 +463,7 @@ describe('envelope observation', () => { const c = client() const batches: (readonly RpcMessage[])[] = [] c.subscribeEnvelopes((batch) => { batches.push(batch) }) - await Promise.all([c.sessions.list({}), c.host.describe({})]) + await Promise.all([c.host.describe({}), c.skills.list({ sessionId: 's1' as never })]) await new Promise((resolve) => { setTimeout(resolve, 0) }) const total = batches.reduce((n, batch) => n + batch.length, 0) expect(total).toBe(4) @@ -774,7 +476,14 @@ describe('resolveBase', () => { urls: string[] = [] protected async doFetch(input: URL): Promise { this.urls.push(input.href) - return Response.json({ type: 'server-response', rpcId: this.lastMinted, result: { ok: true, value: { items: [] } } }) + return Response.json({ + type: 'server-response', + rpcId: this.lastMinted, + result: { + ok: true, + value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true }, + }, + }) } lastMinted = '' @@ -785,18 +494,18 @@ describe('resolveBase', () => { } } const probe = new Probe() - await probe.sessions.list({}) + await probe.host.describe({}) expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//) const globalWithLocation = globalThis as { location?: { origin?: string } } globalWithLocation.location = { origin: 'http://host.example' } try { const probe2 = new Probe() - await probe2.sessions.list({}) + await probe2.host.describe({}) expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//) globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape const probe3 = new Probe() - await probe3.sessions.list({}) + await probe3.host.describe({}) expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//) } finally { delete globalWithLocation.location diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 62d1a371da..6213135c6b 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -1,40 +1,19 @@ import { describe, expect, it } from 'vitest' import { RpcId, transportError } from '../src/api/rpc.ts' import { - clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, - rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema, + clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, + rpcResultSchema, serverResponseSchema, } from '../src/api/rpc.schema.ts' import { z } from 'zod' -import { - contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema, - sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema, - sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema, - sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema, - sessionSearchRequestSchema, sessionSearchValueSchema, sessionSelectModelRequestSchema, - sessionSelectModelValueSchema, sessionSummarySchema, - sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema, -} from '../src/api/sessions.schema.ts' import { hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema, hostDescribeRequestSchema, hostDescribeValueSchema, hostListDirectoryRequestSchema, hostListDirectoryValueSchema, } from '../src/api/host.schema.ts' -import { - workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, - workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, - workspaceDeleteRequestSchema, workspaceDeleteValueSchema, - workspaceInsertBeforeRequestSchema, workspaceInsertBeforeValueSchema, - workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, - workspaceListRequestSchema, workspaceListValueSchema, - workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, -} from '../src/api/workspace.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, } from '../src/api/agent-presets.schema.ts' -import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' -import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' -import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' import { goalEditRequestSchema } from '../src/api/goals.schema.ts' import { subagentPromptRequestSchema } from '../src/api/subagents.schema.ts' @@ -60,32 +39,34 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled') expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') - expect(rpcErrorSchema.parse({ code: 'session-conflict', message: 'm', details: { sessionId: 's', requestedCwd: '/a', existingCwd: '/b' } }).code).toBe('session-conflict') expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone') - expect(rpcErrorSchema.parse({ code: 'workspace-attach-failed', message: 'm', details: { sessionId: 's', workspaceId: 'w' } }).code).toBe('workspace-attach-failed') - expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found') - expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') - expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict') - expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid') - expect(rpcErrorSchema.parse({ - code: 'model-unavailable', - message: 'm', - details: { provider: 'p', model: 'm' }, - }).code).toBe('model-unavailable') + expect(rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: { path: '/x' } }).code).toBe('directory-unreadable') + expect(rpcErrorSchema.parse({ code: 'directory-exists', message: 'm', details: { path: '/x' } }).code).toBe('directory-exists') + expect(rpcErrorSchema.parse({ code: 'directory-create-failed', message: 'm', details: { path: '/x' } }).code).toBe('directory-create-failed') + expect(rpcErrorSchema.parse({ code: 'directory-picker-unavailable', message: 'm', details: { capability: 'none' } }).code).toBe('directory-picker-unavailable') + expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only') + expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked') + expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') + expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') - expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') - expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') - expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') - expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') + expect(rpcErrorSchema.parse({ code: 'settings-rejected', message: 'm', details: { ns: 'n' } }).code).toBe('settings-rejected') + expect(rpcErrorSchema.parse({ code: 'settings-conflict', message: 'm', details: { ns: 'n', expected: 1, actual: 2 } }).code).toBe('settings-conflict') // The credentials producer still emits this code, so the branch has to stay. expect(rpcErrorSchema.parse({ code: 'credential-rejected', message: 'm', details: { ref: 'r' } }).code).toBe('credential-rejected') + expect(rpcErrorSchema.parse({ code: 'model-discovery-failed', message: 'm', details: { settingsNs: 'n' } }).code).toBe('model-discovery-failed') + expect(rpcErrorSchema.parse({ code: 'subagent-parent-unavailable', message: 'm', details: { parentSessionId: 'p' } }).code).toBe('subagent-parent-unavailable') + expect(rpcErrorSchema.parse({ code: 'subagent-not-found', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c' } }).code).toBe('subagent-not-found') + expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic') + expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable') + expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized') + expect(rpcErrorSchema.parse({ code: 'subagent-delivery-unavailable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-delivery-unavailable') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) }) @@ -101,16 +82,12 @@ describe('rpcResultSchema', () => { }) describe('wire full-form schemas', () => { - it('parses the four quadrants and the union discriminates on type', () => { - const cq = { type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} } + it('parses both carrier forms and the union discriminates on type', () => { + const cq = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} } const sr = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } } - const rq = { type: 'server-request', rpcId: 'r2', method: 'session/event', payload: { a: 1 } } - const cr = { type: 'client-response', rpcId: 'r2', result: { ok: true, value: null } } - expect(clientRequestSchema.parse(cq).method).toBe('session.list') + expect(clientRequestSchema.parse(cq).method).toBe('host.describe') expect(serverResponseSchema.parse(sr).rpcId).toBe('r1') - expect(serverRequestSchema.parse(rq).method).toBe('session/event') - expect(clientResponseSchema.parse(cr).rpcId).toBe('r2') - for (const message of [cq, sr, rq, cr]) expect(rpcMessageSchema.parse(message)).toBeTruthy() + for (const message of [cq, sr]) expect(rpcMessageSchema.parse(message)).toBeTruthy() expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() }) @@ -125,171 +102,6 @@ describe('wire full-form schemas', () => { }) }) -describe('rpcReceiptSchema', () => { - it('accepts both receipt branches with the closed reason set', () => { - expect(rpcReceiptSchema.parse({ accepted: true })).toEqual({ accepted: true }) - expect(rpcReceiptSchema.parse({ accepted: false, reason: 'not-pending' })).toEqual({ accepted: false, reason: 'not-pending' }) - expect(rpcReceiptSchema.parse({ accepted: false, reason: 'bad-response' })).toEqual({ accepted: false, reason: 'bad-response' }) - expect(() => rpcReceiptSchema.parse({ accepted: false, reason: 'other' })).toThrow() - }) -}) - -describe('sessions domain schemas', () => { - it('validates ids, summaries, and the event passthrough envelope', () => { - expect(sessionIdSchema.parse('s1')).toBe('s1') - expect(() => sessionIdSchema.parse('')).toThrow() - expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true }) - expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x') - // blank is mandatory: a summary without it fails the parse. - expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow() - const event = sessionEventSchema.parse({ - type: 'user/message', - seq: 0, - time: 1, - data: { any: true }, - }) - expect(event).toMatchObject({ type: 'user/message' }) - expect(() => sessionEventSchema.parse({ - type: 'user/message', - seq: -1, - time: 1, - data: {}, - })).toThrow() - }) - - it('validates the per-method request/value pairs', () => { - expect(sessionListRequestSchema.parse({})).toEqual({}) - expect(sessionListRequestSchema.parse({ cursor: 'c' }).cursor).toBe('c') - expect(sessionListValueSchema.parse({ items: [] }).items).toEqual([]) - expect(sessionSearchRequestSchema.parse({ query: ' exact phrase ' })).toEqual({ query: 'exact phrase' }) - expect(() => sessionSearchRequestSchema.parse({ query: ' ' })).toThrow() - expect(() => sessionSearchRequestSchema.parse({ query: 'bad\0query' })).toThrow(/NUL/) - expect(() => sessionSearchRequestSchema.parse({ query: 'x'.repeat(501) })).toThrow() - expect(sessionSearchValueSchema.parse({ - items: [{ sessionId: 's1', snippet: 'matching text' }], - hasMore: true, - })).toEqual({ - items: [{ sessionId: 's1', snippet: 'matching text' }], - hasMore: true, - }) - expect(sessionSearchValueSchema.parse({ - items: [{ sessionId: 's1', snippet: '😀'.repeat(240) }], - hasMore: false, - }).items[0]?.snippet).toBe('😀'.repeat(240)) - expect(() => sessionSearchValueSchema.parse({ - items: [{ sessionId: 's1', snippet: '😀'.repeat(241) }], - hasMore: false, - })).toThrow(/240 Unicode code points/) - expect(() => sessionSearchValueSchema.parse({ - items: [{ sessionId: '', snippet: 'matching text' }], - hasMore: false, - })).toThrow() - expect(() => sessionSearchValueSchema.parse({ - items: Array.from( - { length: 21 }, - (_, index) => ({ sessionId: `s${index}`, snippet: 'matching text' }), - ), - hasMore: true, - })).toThrow() - expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w') - // The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects. - expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1') - expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/) - expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3) - expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow() - expect(sessionHistoryValueSchema.parse({ - events: [], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }).hasMore).toBe(false) - expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - expect(sessionModelsValueSchema.parse({ - current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, - routable: true, - groups: [{ - id: 'deepseek-official', - name: 'DeepSeek', - models: [{ - id: 'deepseek-v4-flash', - name: 'DeepSeek V4 Flash', - description: 'fast', - reasoning: { - efforts: [ - { id: 'off', name: 'Off' }, - { id: 'max', name: 'Max', description: 'Largest budget' }, - ], - defaultEffort: 'off', - }, - }], - }], - failures: [{ id: 'broken', name: 'Broken', message: 'offline' }], - }).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash') - expect(sessionSelectModelRequestSchema.parse({ - sessionId: 's1', - provider: 'deepseek-official', - model: 'deepseek-v4-pro', - reasoningEffort: 'max', - }).reasoningEffort).toBe('max') - expect(sessionSelectModelValueSchema.parse({ - selected: { provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' }, - }).selected.reasoningEffort).toBe('max') - expect(() => sessionSelectModelRequestSchema.parse({ - sessionId: 's1', - provider: '', - model: 'm', - })).toThrow() - expect(() => sessionSelectModelRequestSchema.parse({ - sessionId: 's1', - provider: 'deepseek-official', - model: 'm', - reasoningEffort: '', - })).toThrow() - expect(() => sessionModelsValueSchema.parse({ - current: { provider: 'deepseek-official', model: 'm' }, - groups: [{ - id: 'deepseek-official', - name: 'DeepSeek', - models: [{ id: 'm', name: 'M', reasoning: { efforts: [] } }], - }], - failures: [], - })).toThrow() - const prompt = sessionPromptRequestSchema.parse({ - sessionId: 's1', - mode: 'queue', - content: [{ type: 'text', text: 'hi' }], - clientTimeZone: 'Asia/Shanghai', - }) - expect(prompt.mode).toBe('queue') - expect(prompt.clientTimeZone).toBe('Asia/Shanghai') - expect(sessionPromptRequestSchema.parse({ - sessionId: 's1', mode: 'queue', content: [], - }).clientTimeZone).toBeUndefined() - expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow() - expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true) - // The command slot appears only when the prompt dispatched a slash command. - const dispatched = sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success', text: 'Goal set' } }) - expect(dispatched.command?.text).toBe('Goal set') - expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' }) - expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow() - expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - expect(sessionUpdateQueueRequestSchema.parse({ - sessionId: 's1', - itemId: 'i1', - action: { kind: 'edit', content: [{ type: 'text', text: 'next' }] }, - }).action.kind).toBe('edit') - expect(sessionUpdateQueueRequestSchema.parse({ - sessionId: 's1', itemId: 'i1', action: { kind: 'remove' }, - }).action.kind).toBe('remove') - expect(() => sessionUpdateQueueRequestSchema.parse({ - sessionId: 's1', itemId: 'i1', action: { kind: 'promote' }, - })).toThrow() - expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true) - expect(sessionUpdateQueueValueSchema.parse({ accepted: true }).accepted).toBe(true) - expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 }) - }) -}) - describe('subagent domain schemas', () => { it('carries optional request-local browser-zone provenance on prompts', () => { expect(subagentPromptRequestSchema.parse({ @@ -347,70 +159,6 @@ describe('host domain schemas', () => { }) }) -describe('workspace domain schemas', () => { - const view = { - workspaceId: 'w1', path: '/p', title: 'p', sessionIds: ['s1'], - createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z', - } - - it('validates ids, the view row, and list request/value', () => { - expect(workspaceIdSchema.parse('w1')).toBe('w1') - expect(() => workspaceIdSchema.parse('')).toThrow() - expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1']) - expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow() - expect(workspaceListRequestSchema.parse({})).toEqual({}) - expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1) - expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow() - }) - - it('archiveSession request/value carry the id and the full updated set', () => { - expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') - expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow() - expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds) - .toEqual(['s1', 's2']) - expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() - }) - - it('insertSessionBefore accepts an anchored and an anchorless move', () => { - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') - expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() - expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() - expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') - }) - - it('create requires a path', () => { - expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') - expect(() => workspaceCreateRequestSchema.parse({})).toThrow() - // The retired create-by-name spelling stays a clean schema rejection. - expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow() - expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) - }) - - it('rename requires a non-blank title (both refine arms)', () => { - expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new') - expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/) - expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') - }) - - it('validates workspace deletion payload and receipt', () => { - expect(workspaceDeleteRequestSchema.parse({ workspaceId: 'w1' }).workspaceId).toBe('w1') - expect(() => workspaceDeleteRequestSchema.parse({})).toThrow() - expect(workspaceDeleteValueSchema.parse({ deleted: true })).toEqual({ deleted: true }) - expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() - }) - - it('insertBefore accepts an anchored or anchorless Workspace move and returns the complete order', () => { - expect(workspaceInsertBeforeRequestSchema.parse({ - workspaceId: 'w1', beforeWorkspaceId: 'w2', - }).beforeWorkspaceId).toBe('w2') - expect(workspaceInsertBeforeRequestSchema.parse({ workspaceId: 'w1' }).beforeWorkspaceId) - .toBeUndefined() - expect(() => workspaceInsertBeforeRequestSchema.parse({ beforeWorkspaceId: 'w2' })).toThrow() - expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds) - .toEqual(['w2', 'w1']) - }) -}) - describe('skills domain schemas', () => { it('validates the list request/value pair', () => { expect(skillListRequestSchema.parse({ sessionId: 's1' })).toEqual({ sessionId: 's1' }) @@ -439,115 +187,6 @@ describe('goals domain schemas', () => { }) }) -describe('events frame schemas', () => { - it('accepts every mux frame branch', () => { - const frames = [ - { type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } }, - { type: 'session/subscribed', sessionId: 's', lastSeq: -1 }, - { type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' }, - { type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' }, - { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, - { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' }, - { type: 'session/queue', sessionId: 's', items: [ - { - id: 'm1', - placement: 'queued', - message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, - }, - ] }, - { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, - { type: 'session/jobs', sessionId: 's', jobs: [] }, - { type: 'session/jobs', sessionId: 's', jobs: [ - { id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 }, - { id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 }, - ] }, - { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, - ] - for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) - expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow() - for (const invalid of [ - { type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 }, - { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 }, - { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 }, - // A producer kind stays an open string, but the closed status set and - // the identity/label bounds are the carrier's own wire contract. - { type: 'session/jobs', sessionId: 's', jobs: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] }, - { type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] }, - ]) expect(() => muxFrameSchema.parse(invalid)).toThrow() - expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q') - }) - - it('rejects an empty question batch (ask() guarantees at least one, so an empty frame is host breakage)', () => { - expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow() - }) - - it('carries a question presentation intent through, and rejects an unknown one', () => { - const intent = { kind: 'plan-review', approve: 'Approve' } - expect(askUserQuestionItemSchema.parse({ - id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }], intent, - }).intent).toEqual(intent) - // An unrecognised tag is a rejected frame, not a silently generic render. - for (const invalid of [{ kind: 'plan-review' }, { kind: 'poll', approve: 'Approve' }, { approve: 'Approve' }]) { - expect(() => askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?', intent: invalid })).toThrow() - } - }) - - it('accepts every queue placement and rejects unknown placements', () => { - const item = (placement: string) => ({ type: 'session/queue', sessionId: 's', items: [{ - id: 'm', placement, - message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } }, - }] }) - for (const placement of ['queued', 'steering', 'context']) { - expect(() => muxFrameSchema.parse(item(placement))).not.toThrow() - } - expect(() => muxFrameSchema.parse(item('bogus'))).toThrow() - }) - - it('rejects a queue snapshot with malformed items', () => { - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'm', role: 'assistant', content: [], source: { kind: 'user' } }] })).toThrow() - }) - - it('accepts every host frame branch', () => { - const frames = [ - { type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' }, - { type: 'host/session-added', sessionId: 's', blank: true }, - { type: 'host/session-removed', sessionId: 's' }, - { type: 'host/session-status', sessionId: 's', running: true }, - { type: 'host/agent-error', sessionId: 's', message: 'boom' }, - { type: 'host/workspace-changed', workspace: { - workspaceId: 'w', path: '/w', title: 'w', sessionIds: [], - createdAt: '0', updatedAt: '0', - } }, - { type: 'host/workspace-removed', workspaceId: 'w' }, - { type: 'host/remote-event', event: 'commands/change', args: [] }, - { type: 'host/remote-event', event: 'settings/document-updated', args: ['ns', 3] }, - { type: 'host/remote-event', event: 'agent-preset/selected', args: ['s', 'minimal'] }, - { type: 'host/remote-event', event: 'llm/adapters-updated', args: [] }, - { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, - ] - for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) - }) -}) - -describe('respond payload schemas', () => { - it('validates approval and question answer payloads', () => { - expect(approvalRequestIdSchema.parse('a1')).toBe('a1') - const approval = approvalResponsePayloadSchema.parse({ sessionId: 's', approvalId: 'a', outcome: 'rejected' }) - expect(approval.outcome).toBe('rejected') - expect(() => approvalResponsePayloadSchema.parse({ sessionId: 's', approvalId: 'a', outcome: 'cancelled' })).toThrow() - const answer = askUserQuestionAnswerSchema.parse({ answers: [{ id: 'q', selected: ['x'], custom: 'c' }] }) - expect(answer.answers[0]?.selected).toEqual(['x']) - const payload = questionResponsePayloadSchema.parse({ sessionId: 's', answer: { answers: [] } }) - expect(payload.sessionId).toBe('s') - }) -}) - describe('agent-preset schemas', () => { it('accepts a roster row and rejects an unknown trust', () => { expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true })) diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 5b5fcaa216..aa4137e031 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -10,7 +10,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { unzipSync, strFromU8 } from 'fflate' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' @@ -77,7 +76,6 @@ async function buildApi( } = {}, ) { const ctx = new Context() - await ctx.plugin(UserQuestionService) const query = services.query ?? true const persistence = services.persistence ?? true if (query) { @@ -129,30 +127,17 @@ describe('session export compression config', () => { it('defaults to level 6 and rejects values outside the integer 0-9 range', () => { expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6, - coldBlankProbeMaxBytes: 1024, }) expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 })) - .toEqual({ sessionExportCompressionLevel: 0, coldBlankProbeMaxBytes: 1024 }) + .toEqual({ sessionExportCompressionLevel: 0 }) expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 })) - .toEqual({ sessionExportCompressionLevel: 9, coldBlankProbeMaxBytes: 1024 }) + .toEqual({ sessionExportCompressionLevel: 9 }) for (const value of [-1, 10, 1.5]) { expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow() } }) }) -describe('cold blank probe config', () => { - it('accepts a per-Session byte bound including zero and rejects invalid bounds', () => { - expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 0 })) - .toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 0 }) - expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 2048 })) - .toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 2048 }) - for (const value of [-1, 1.5]) { - expect(() => ApiProxyService.Config({ coldBlankProbeMaxBytes: value })).toThrow() - } - }) -}) - describe('session.export download endpoint', () => { it('streams a ZIP with the root artifact verbatim under its original filename', async () => { const api = await buildApi({ 'session-root': artifact('session-root') }) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 4ae705f1fa..7133fd8c15 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../../api/remotes/tsconfig.host.json" + "path": "../../api/session-controller/tsconfig.host.json" }, { "path": "../../util/brand" @@ -48,23 +48,11 @@ "path": "../../core/session" }, { - "path": "../../core/tools" + "path": "../../core/scope" }, { "path": "../../session/session-persistence" }, - { - "path": "../../session/session-projection" - }, - { - "path": "../../session/session-projection-cache" - }, - { - "path": "../../session-query/session-query" - }, - { - "path": "../../session/session-title" - }, { "path": "../../session-query/session-query" }, @@ -74,27 +62,12 @@ { "path": "../../skill/skill" }, - { - "path": "../../jobs/jobs" - }, { "path": "../../interaction/commands" }, - { - "path": "../../interaction/user-approval" - }, - { - "path": "../../interaction/user-questions" - }, - { - "path": "../../workspace/workspace" - }, { "path": "../directory-picker" }, - { - "path": "../../extensions/cordis-host-runner" - }, { "path": "../../runtime-diagnostics/invariants" }, diff --git a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts index 0c28660c64..2b63067475 100644 --- a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts @@ -4,11 +4,22 @@ import { CallId } from '@deepseek-ai/dsh-llm' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import UserQuestionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions' +import UserQuestionService, { + type AskUserQuestionAnswer, + type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-questions' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' const testToolSignal = new AbortController().signal +interface QuestionAnswerer { + ask(request: AskUserQuestionRequest): Promise +} + +function registerQuestionAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void { + return ctx.on('user-questions/request', request => answerer.ask(request)) +} + interface OptionSchemaShape { properties: { questions: { @@ -78,7 +89,7 @@ describe('ask_user_question tool', () => { it('asks the registered user-questions provider and projects structured answers to text', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'pkg', selected: ['pnpm'] }] } @@ -114,7 +125,7 @@ describe('ask_user_question tool', () => { it('passes recommended option labels through without adding schema fields', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] } @@ -145,7 +156,7 @@ describe('ask_user_question tool', () => { it('projects custom answers and multi-select choices', async () => { const ctx = await setup() - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask() { return { answers: [ @@ -198,7 +209,7 @@ describe('ask_user_question tool', () => { it('passes the tool abort signal to the user-questions request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } @@ -219,7 +230,7 @@ describe('ask_user_question tool', () => { it('passes optional header and a resumed runtime root through to the user-questions request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } @@ -259,7 +270,7 @@ describe('ask_user_question tool', () => { it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index b0618f6b3d..5d03b3186c 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -10,7 +10,6 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' -import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -18,44 +17,10 @@ declare module '@deepseek-ai/cordis' { interface Context { approval: ApprovalService } - - interface Events { - /** - * Ask composed answerers for one decision. Return an outcome to claim the - * request or call `next()`; failure yields the fail-closed default. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param req - the pending decision (agent, tool identity, reason, signal). - * @mode waterfall - */ - 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise - } } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { - /** - * An approval question was put to the answerer chain — log-only audit - * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs - * it with the `approval/decided` that always follows; `toolName` is the - * tool the question is about, `callId` the exact tool call when the asker - * had one, `reason` the asker's human-readable explanation (e.g. a hook's - * permission-decision reason). - */ - 'approval/asked': { - id: ApprovalRequestId - toolName: string - callId?: CallId - reason?: string - } - /** - * The outcome of a prior `approval/asked` (same `id`) — log-only audit. - * Exactly one per ask, appended when the outcome is known: a decision, a - * cancellation, or the fail-closed `'unavailable'`. - */ - 'approval/decided': { - id: ApprovalRequestId - outcome: ApprovalOutcome - } /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy @@ -73,7 +38,7 @@ declare module '@deepseek-ai/dsh-session/types' { } import { ApprovalRequestId } from './types.ts' -import type { ApprovalOutcome } from './types.ts' +import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts' export { ApprovalRequestId } from './types.ts' export type { ApprovalOutcome } from './types.ts' @@ -150,7 +115,7 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ -export interface ApprovalRequest { +export interface ApprovalRequest extends ApprovalRequestEvent { /** * The agent on whose behalf the question is asked. Routes the question (a * UI answerer only answers for agents it owns) and receives the audit @@ -316,7 +281,7 @@ export class ApprovalService extends Service { // the containment into the caller. const answer: Promise = Promise.resolve().then( () => this.ctx.waterfall( - scopeTarget(this, req.agent), 'approval/request', req, + scopeTarget(req.agent, req.agent), 'approval/request', req, () => Promise.resolve('unavailable'), ), ).then( diff --git a/packages/interaction/user-approval/src/types.ts b/packages/interaction/user-approval/src/types.ts index 5a862ea546..9a4185601d 100644 --- a/packages/interaction/user-approval/src/types.ts +++ b/packages/interaction/user-approval/src/types.ts @@ -6,6 +6,9 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent/types' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' /** * Pairs one `approval/asked` audit event with its `approval/decided`. @@ -27,3 +30,62 @@ export function ApprovalRequestId(id: string): ApprovalRequestId { * request, or unavailable answerer. Callers fail closed on `unavailable`. */ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * An approval question was put to the answerer chain — log-only audit + * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs + * it with the `approval/decided` that always follows; `toolName` is the + * tool the question is about, `callId` the exact tool call when the asker + * had one, `reason` the asker's human-readable explanation (e.g. a hook's + * permission-decision reason). + */ + 'approval/asked': { + id: ApprovalRequestId + toolName: string + callId?: CallId + reason?: string + } + /** + * The outcome of a prior `approval/asked` (same `id`) — log-only audit. + * Exactly one per ask, appended when the outcome is known: a decision, a + * cancellation, or the fail-closed `'unavailable'`. + */ + 'approval/decided': { + id: ApprovalRequestId + outcome: ApprovalOutcome + } + } +} + +/** Client-safe payload declared for the approval answerer waterfall. */ +export interface ApprovalRequestEvent { + /** Agent identity projected to the corresponding Client Context in transit. */ + readonly agent: Agent + /** Tool whose operation requires a decision. */ + readonly toolName: string + /** Exact tool call being decided, when available. */ + readonly callId?: CallId + /** Human-readable reason supplied by the asker. */ + readonly reason?: string + /** Cancellation lifetime of the pending request. */ + readonly signal?: AbortSignal +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Ask composed answerers for one decision. Return an outcome to claim the + * request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - pending approval request. + * @mode waterfall + */ + 'approval/request'( + this: Scoped, + req: ApprovalRequestEvent, + next: () => Promise, + ): Promise + } +} diff --git a/packages/interaction/user-questions/README.i18n.yaml b/packages/interaction/user-questions/README.i18n.yaml index bdcbeee898..a2007fe9f6 100644 --- a/packages/interaction/user-questions/README.i18n.yaml +++ b/packages/interaction/user-questions/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/user-questions/README.md -README.md: 53459c39c75d5d3907b002239c83db5f82e024f5 -README.zh.md: 3a1f016aef6efa0a2167523fd4e1a938fa53eb71 +README.md: 2869fabb2737163bbb4ccde48997d4486169e46d +README.zh.md: cfe72d320cac426f0b770b1d2f78c528cda2df88 diff --git a/packages/interaction/user-questions/README.md b/packages/interaction/user-questions/README.md index 53459c39c7..2869fabb27 100644 --- a/packages/interaction/user-questions/README.md +++ b/packages/interaction/user-questions/README.md @@ -8,8 +8,7 @@ User-interaction Service Definition. It owns `ctx.userQuestions`, the service a ### Public API -- `ctx.userQuestions.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it. -- `ctx.userQuestions.ask(request): Promise` Ask the active provider and wait for the answer. +- `ctx.userQuestions.ask(request): Promise` Dispatch the answerer waterfall and wait for the first accepted answer. ### Key Types @@ -17,12 +16,11 @@ User-interaction Service Definition. It owns `ctx.userQuestions`, the service a - `AskUserQuestionOption` — `{ label, description? }`. - `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below. - `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`. -- `UserQuestionProvider` — UI implementation with `ask(request)`. -- `UserQuestionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`. +- `UserQuestionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`. For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. -When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. Agentless programmatic requests retain the existing provider path. +When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. The Web answerer receives only Agent-scoped requests; an agentless programmatic request remains available to unscoped local waterfall listeners and fails with `NO_PROVIDER` when none accepts it. ### Presentation intent @@ -30,11 +28,11 @@ When a request carries an agent, `ask()` authenticates its exact identity throug ## Role -This is the Service Definition package. Consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this service; the Web host runtime supplies the shipped Service Provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. +This is the Service Definition package. Consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this service; the Web client contributes an Agent-scoped answerer through Remote Events. The loop stays unchanged: a tool call awaits the waterfall result, and that result resumes the normal agent loop. ## Model Experience -Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`, `Error: no user-questions provider is registered`, or `Error: `. Waiting for the human adds no tokens. +Indirectly, through `dsh-tool-ask-user`, which retains a successful answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`, `Error: no user-questions answerer accepted the request`, or `Error: `. Waiting for the human adds no tokens. #### KV Cache effect @@ -42,5 +40,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **One provider per context** — there is no routing or fan-out to multiple UIs; a second registration throws `DUPLICATE_PROVIDER`, and with none registered `ask()` throws `NO_PROVIDER` rather than degrading. +- **Agent-scoped Web answering** — Remote Events route the shipped Web answerer only when the request carries a live Agent scope; agentless callers need an unscoped local waterfall listener. - **The vocabulary is the question-form shape only** — selectable options plus optional custom text; richer interaction shapes (file pickers, diff-preview confirmations) have no seam vocabulary yet. diff --git a/packages/interaction/user-questions/README.zh.md b/packages/interaction/user-questions/README.zh.md index 3a1f016aef..cfe72d320c 100644 --- a/packages/interaction/user-questions/README.zh.md +++ b/packages/interaction/user-questions/README.zh.md @@ -8,8 +8,7 @@ ### 公开 API -- `ctx.userQuestions.registerProvider(provider): () => void` 注册 UI 侧提供方。同一上下文中只能有一个活跃提供方;dispose(资源释放)会将其注销。 -- `ctx.userQuestions.ask(request): Promise` 向活跃提供方提问并等待回答。 +- `ctx.userQuestions.ask(request): Promise` 派发回答者 waterfall,并等待第一个接受请求的回答。 ### 关键类型 @@ -17,12 +16,11 @@ - `AskUserQuestionOption`:`{ label, description? }`。 - `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。 - `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。 -- `UserQuestionProvider`:包含 `ask(request)` 的 UI 实现。 -- `UserQuestionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER`、`ASK_ABORTED`、`CALLER_NOT_LIVE` 和 `DELEGATED_CALLER` 等代码。 +- `UserQuestionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`ASK_ABORTED`、`CALLER_NOT_LIVE` 和 `DELEGATED_CALLER` 等代码。 对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 -请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;归属于另一个 agent 的存活子级即使持久化记录的委托深度为零也会被拒绝。不含 agent 的程序化请求继续沿用现有提供方路径。 +请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;归属于另一个 agent 的存活子级即使持久化记录的委托深度为零也会被拒绝。Web 回答者只接收带 Agent scope 的请求;不含 agent 的程序化请求仍会交给本地未限定 scope 的 waterfall listener,若无人接受则以 `NO_PROVIDER` 失败。 ### 呈现意图 @@ -30,11 +28,11 @@ ## 职责 -这是 Service Definition 包。`@deepseek-ai/dsh-tool-ask-user` 等 Consumer 依赖此服务;Web 宿主运行时提供随产品交付的 Service Provider。循环保持不变:工具调用等待 Promise,工具结果随后恢复正常的 agent loop(智能体循环)。 +这是 Service Definition 包。`@deepseek-ai/dsh-tool-ask-user` 等 Consumer 依赖此服务;Web Client 通过 Remote Events 贡献带 Agent scope 的回答者。循环保持不变:工具调用等待 waterfall 结果,该结果随后恢复正常的 agent loop(智能体循环)。 ## 模型体验 -间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: human interaction requires the exact live calling agent when an agent is supplied`、`Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`、`Error: no user-questions provider is registered` 或 `Error: `。等待人类回答不会增加 token。 +间接地,通过 `dsh-tool-ask-user`:它会将成功回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: human interaction requires the exact live calling agent when an agent is supplied`、`Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`、`Error: no user-questions answerer accepted the request` 或 `Error: `。等待人类回答不会增加 token。 #### KV Cache 影响 @@ -42,5 +40,5 @@ ## 已知限制与暂缓事项 -- **每个上下文只能有一个提供方**:不支持路由或扇出到多个 UI;第二次注册会抛出 `DUPLICATE_PROVIDER`,未注册任何提供方时,`ask()` 会抛出 `NO_PROVIDER`,而不会降级。 +- **带 Agent scope 的 Web 回答**:Remote Events 仅在请求带有存活 Agent scope 时路由随产品交付的 Web 回答者;agentless 调用方需要本地未限定 scope 的 waterfall listener。 - **词汇仅包含问题表单形态**:可供选择的选项加可选的自定义文本;更丰富的交互形态(文件选择器、diff 预览确认)尚无 seam 词汇。 diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 07dafa3341..21b01aeae3 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -40,12 +40,14 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 0862f49cd0..1d5d136426 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -1,15 +1,16 @@ /** * Service Definition for the user-questions capability seam (`ctx.userQuestions`): a UI-backed service for * pausing an agent tool call until the human answers a question. The model- - * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide - * the single active provider. + * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages compose + * answerers on the Agent-scoped Cordis waterfall. * * @module @deepseek-ai/dsh-user-questions */ import { Context, Service } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { scopeTarget } from '@deepseek-ai/dsh-scope' declare module '@deepseek-ai/cordis' { interface Context { @@ -17,7 +18,9 @@ declare module '@deepseek-ai/cordis' { } } -import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts' +import type { + AskUserQuestionAnswer, AskUserQuestionRequestEvent, +} from './types.ts' export type { AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem, @@ -25,19 +28,7 @@ export type { } from './types.ts' /** Request for a human answer. */ -export interface AskUserQuestionRequest { - /** Questions to display. */ - questions: AskUserQuestionItem[] - /** Exact live calling agent, when the request came from an agent tool call. */ - agent?: Agent - /** Abort signal for the owning tool/step. */ - signal?: AbortSignal -} - -/** UI-side provider for user questions. */ -export interface UserQuestionProvider { - ask(request: AskUserQuestionRequest): Promise -} +export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {} /** Stable error taxonomy for user-questions failures. */ export class UserQuestionError extends HarnessError { @@ -47,35 +38,37 @@ export class UserQuestionError extends HarnessError { } } -/** `ctx.userQuestions`: one active UI provider plus an `ask()` API. */ -export class UserQuestionService extends Service { - private provider: UserQuestionProvider | undefined +function abortedQuestion(cause?: unknown): UserQuestionError { + return new UserQuestionError( + 'ask_user_question was aborted before the user answered', + 'ASK_ABORTED', + cause === undefined ? undefined : { cause }, + ) +} +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function restoreUserQuestionError(reason: unknown): unknown { + if (reason instanceof UserQuestionError) return reason + if (isRecord(reason) + && reason.name === 'UserQuestionError' + && typeof reason.message === 'string' + && typeof reason.code === 'string') { + return new UserQuestionError(reason.message, reason.code, { cause: reason }) + } + return reason +} + +/** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */ +export class UserQuestionService extends Service { constructor(ctx: Context) { super(ctx, 'userQuestions') } /** - * Register the UI provider. Only one provider may be active in a context. - * - * @param provider UI-side implementation that collects answers. - * @returns Disposer that unregisters this provider. - */ - registerProvider(provider: UserQuestionProvider): () => void { - const dispose = this.ctx.effect(function* (this: UserQuestionService) { - if (this.provider !== undefined) { - throw new UserQuestionError('a user-questions provider is already registered', 'DUPLICATE_PROVIDER') - } - this.provider = provider - yield () => { - this.provider = undefined - } - }.bind(this), 'userInteraction.registerProvider()') - return () => void dispose() - } - - /** - * Ask the active UI provider and wait for the user's answer. + * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the * exact live runtime root. Runtime ownership, not durable session lineage, @@ -85,13 +78,14 @@ export class UserQuestionService extends Service { * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. - * @throws {UserQuestionError} code `CALLER_NOT_LIVE` when a supplied - * agent is not the registry's exact live instance, or `DELEGATED_CALLER` - * when that live agent is owned by another agent. + * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal + * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent + * is not the registry's exact live instance, or `DELEGATED_CALLER` when + * that live agent is owned by another agent. */ async ask(request: AskUserQuestionRequest): Promise { if (request.signal?.aborted) { - throw new UserQuestionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED') + throw abortedQuestion() } if (request.questions.length === 0) { throw new UserQuestionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS') @@ -133,10 +127,27 @@ export class UserQuestionService extends Service { 'BAD_INTENT') } } - if (this.provider === undefined) { - throw new UserQuestionError('no user-questions provider is registered', 'NO_PROVIDER') + const noAnswerer = () => Promise.reject(new UserQuestionError( + 'no user-questions answerer accepted the request', + 'NO_PROVIDER', + )) + try { + return await (agent === undefined + ? this.ctx.waterfall('user-questions/request', request, noAnswerer) + : this.ctx.waterfall( + scopeTarget(agent, agent), + 'user-questions/request', + { ...request, agent }, + noAnswerer, + )) + } catch (error) { + const restored = restoreUserQuestionError(error) + if (restored instanceof UserQuestionError) throw restored + if (request.signal?.aborted) { + throw abortedQuestion(error) + } + throw restored } - return this.provider.ask(request) } } diff --git a/packages/interaction/user-questions/src/types.ts b/packages/interaction/user-questions/src/types.ts index 147d416f65..1818a63d91 100644 --- a/packages/interaction/user-questions/src/types.ts +++ b/packages/interaction/user-questions/src/types.ts @@ -1,9 +1,7 @@ -/** - * Wire-safe question and answer types, free of cordis/service imports so browser - * type chains (apiproxy api → client) can consume them without loading this - * package's Context augmentation. - * @module @deepseek-ai/dsh-user-questions/types - */ +/** Client-safe question, answer, and event types. @module @deepseek-ai/dsh-user-questions/types */ + +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent/types' /** One selectable answer offered to the user. */ export interface AskUserQuestionOption { @@ -64,3 +62,30 @@ export interface AskUserQuestionAnswer { /** Structured answers keyed by question id. */ answers: AskUserQuestionAnswerItem[] } + +/** Client-safe payload declared for the user-question answerer waterfall. */ +export interface AskUserQuestionRequestEvent { + /** Questions to display. */ + questions: AskUserQuestionItem[] + /** Agent identity projected to the corresponding Client Context in transit. */ + agent?: Agent + /** Cancellation lifetime of the pending request. */ + signal?: AbortSignal +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Ask composed answerers for structured user input. Return an answer to + * claim the request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param request - pending user-question request. + * @mode waterfall + */ + 'user-questions/request'( + this: Scoped, + request: AskUserQuestionRequestEvent, + next: () => Promise, + ): Promise + } +} diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index 4c6c153a4a..02ee19ea26 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -3,17 +3,27 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import UserQuestionService, { UserQuestionError, + type AskUserQuestionAnswer, type AskUserQuestionRequest, - type UserQuestionProvider, } from '@deepseek-ai/dsh-user-questions' -function provider(answer = 'approved'): UserQuestionProvider & { seen: AskUserQuestionRequest[] } { +interface QuestionAnswerer { + ask(request: AskUserQuestionRequest): Promise +} + +function registerAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void { + return ctx.on('user-questions/request', request => answerer.ask(request)) +} + +function provider(answer = 'approved'): QuestionAnswerer & { seen: AskUserQuestionRequest[] } { const seen: AskUserQuestionRequest[] = [] return { seen, async ask(request) { seen.push(request) - return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] } + return { + answers: request.questions.map(question => ({ id: question.id, selected: [answer] })), + } }, } } @@ -31,12 +41,13 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = provider('yes') - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) + const questions = [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'yes' }] }] - const result = await ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }) + const result = await ctx.userQuestions.ask({ questions }) expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] }) - expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }]) + expect(p.seen).toEqual([{ questions }]) }) it('rejects ask requests when no provider is registered', async () => { @@ -51,7 +62,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = provider() - const dispose = ctx.userQuestions.registerProvider(p) + const dispose = registerAnswerer(ctx, p) dispose() dispose() @@ -60,20 +71,28 @@ describe('UserQuestionService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) - it('rejects duplicate providers instead of replacing the active UI', async () => { + it('delegates through composed answerers', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) - ctx.userQuestions.registerProvider(provider('first')) + const delegated = vi.fn() + ctx.on('user-questions/request', (_request, next) => { + delegated() + return next() + }) + const p = provider('second') + registerAnswerer(ctx, p) - expect(() => ctx.userQuestions.registerProvider(provider('second'))) - .toThrow(UserQuestionError) + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'second' }] }], + })).resolves.toEqual({ answers: [{ id: 'confirm', selected: ['second'] }] }) + expect(delegated).toHaveBeenCalledOnce() }) it('fails before reaching the provider when the signal is already aborted', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const controller = new AbortController() controller.abort() @@ -82,11 +101,91 @@ describe('UserQuestionService', () => { expect(p.ask).not.toHaveBeenCalled() }) + it('normalizes an in-flight signal cancellation to ASK_ABORTED', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const pending = Promise.withResolvers() + registerAnswerer(ctx, { ask: () => pending.promise }) + const controller = new AbortController() + const abortReason = new DOMException('This operation was aborted', 'AbortError') + + const answer = ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + signal: controller.signal, + }) + controller.abort(abortReason) + pending.reject(abortReason) + + await expect(answer).rejects.toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_ABORTED', + cause: abortReason, + }) + }) + + it('preserves a domain rejection when its provider also aborts the signal', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const controller = new AbortController() + const cancelled = new UserQuestionError('the user cancelled ask_user_question', 'ASK_CANCELLED') + registerAnswerer(ctx, { + ask: () => { + controller.abort() + return Promise.reject(cancelled) + }, + }) + + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + signal: controller.signal, + })).rejects.toBe(cancelled) + }) + + it('restores a transported provider rejection to UserQuestionError', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const transported = Object.assign(new Error('the user cancelled ask_user_question'), { + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + }) + registerAnswerer(ctx, { ask: () => Promise.reject(transported) }) + + const rejection = await ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + }).then( + () => undefined, + (error: unknown) => error, + ) + + expect(rejection).toBeInstanceOf(UserQuestionError) + expect(rejection).toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + cause: transported, + }) + }) + + it.each([ + ['an ordinary Error', new Error('provider failed')], + ['a namesake Error without a string code', Object.assign(new Error('provider failed'), { + name: 'UserQuestionError', + })], + ['a non-Error rejection', { name: 'UserQuestionError', code: 'ASK_CANCELLED' }], + ])('preserves %s from the provider', async (_label, rejection) => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + registerAnswerer(ctx, { ask: vi.fn().mockRejectedValue(rejection) }) + + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + })).rejects.toBe(rejection) + }) + it('rejects empty question batches before reaching the provider', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) await expect(ctx.userQuestions.ask({ questions: [] })) .rejects.toMatchObject({ name: 'UserQuestionError', code: 'EMPTY_QUESTIONS' }) @@ -98,7 +197,7 @@ describe('UserQuestionService', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const root = stubAgent('root', 0) const child = stubAgent('child', 0) ctx.agents.enter(root, undefined) @@ -120,12 +219,12 @@ describe('UserQuestionService', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = provider('yes') - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const agent = stubAgent('resumed-root', 1) ctx.agents.enter(agent, undefined) const result = await ctx.userQuestions.ask({ - questions: [{ id: 'confirm', question: 'Proceed?' }], + questions: [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'yes' }] }], agent, }) @@ -136,7 +235,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) await expect(ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], @@ -150,7 +249,7 @@ describe('UserQuestionService', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const live = stubAgent('same-id') ctx.agents.enter(live, undefined) @@ -161,11 +260,41 @@ describe('UserQuestionService', () => { expect(p.ask).not.toHaveBeenCalled() }) + it('restores a transported UserQuestionError to the public error class', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const transported = Object.assign(new Error('the user cancelled ask_user_question'), { + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + }) + registerAnswerer(ctx, { ask: () => Promise.reject(transported) }) + + const failure = await ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + }).then(() => undefined, (error: unknown) => error) + + expect(failure).toBeInstanceOf(UserQuestionError) + expect(failure).toMatchObject({ + name: 'UserQuestionError', code: 'ASK_CANCELLED', cause: transported, + }) + }) + + it('preserves a provider rejection outside the UserQuestionError taxonomy', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const failure = new Error('provider failed') + registerAnswerer(ctx, { ask: () => Promise.reject(failure) }) + + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + })).rejects.toBe(failure) + }) + it('rejects an intent whose approve label names none of its own options', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' } // A wrong label among offered options, and no options offered at all. @@ -185,7 +314,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) // Detail IS the plan for this intent, so a UI honouring it would ask the // user to approve something they cannot see. @@ -203,12 +332,12 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = provider('Approve') - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const intent = { kind: 'plan-review', approve: 'Approve' } as const const result = await ctx.userQuestions.ask({ questions: [ - { id: 'plain', question: 'Proceed?' }, + { id: 'plain', question: 'Proceed?', options: [{ label: 'Approve' }] }, { id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent, @@ -216,7 +345,10 @@ describe('UserQuestionService', () => { ], }) - expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }]) + expect(result.answers).toEqual([ + { id: 'plain', selected: ['Approve'] }, + { id: 'plan-review', selected: ['Approve'] }, + ]) expect(p.seen[0]?.questions[1]?.intent).toEqual(intent) }) }) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index eea2beddb0..ec6eec8167 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -7,7 +7,7 @@ import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import UserQuestionService, { - UserQuestionError, type AskUserQuestionRequest, + UserQuestionError, type AskUserQuestionAnswer, type AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-questions' import CommandRuntime from '@deepseek-ai/dsh-commands' import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime' @@ -17,6 +17,14 @@ import type { PlanModeConfig } from '../src/index.ts' const TEST_PLAN_SECTION = 'Test plan mode instructions.' const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig +interface QuestionAnswerer { + ask(request: AskUserQuestionRequest): Promise +} + +function registerQuestionAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void { + return ctx.on('user-questions/request', request => answerer.ask(request)) +} + /** * Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and * `ToolRuntime` services, with fake Agents carrying real `Session`s and a @@ -733,7 +741,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(UserQuestionService) const asked: AskUserQuestionRequest[] = [] if (answer !== undefined) { - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: (request) => { asked.push(request) return Promise.resolve({ answers: [{ id: 'plan-review', ...answer }] }) @@ -803,7 +811,7 @@ describe('exit_plan_mode', () => { const { ctx, agent } = await setupWithReview() const result = await callExit(ctx, agent) expect(result.isError).toBe(true) - expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions provider is registered' }]) + expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions answerer accepted the request' }]) expect(foldPlanMode(agent.session.events)).toBe(true) }) @@ -812,7 +820,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const ask = vi.fn(async () => ({ answers: [{ id: 'plan-review', selected: ['Approve'] }] })) - ctx.userQuestions.registerProvider({ ask }) + registerQuestionAnswerer(ctx, { ask }) const root = await agentWithSession(ctx, 'review-root') const child = await agentWithSession(ctx, 'review-child', { active: true, owner: root }) @@ -865,7 +873,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const asked: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: (request) => { asked.push(request) return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] }) @@ -964,7 +972,7 @@ describe('exit_plan_mode', () => { it('treats duplicate review answer items as non-consent', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: () => Promise.resolve({ answers: [ { id: 'plan-review', selected: ['Approve'] }, { id: 'plan-review', selected: ['Keep planning'] }, @@ -978,7 +986,7 @@ describe('exit_plan_mode', () => { it('a missing answer item reads as keep-planning', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ ask: () => Promise.resolve({ answers: [] }) }) + registerQuestionAnswerer(ctx, { ask: () => Promise.resolve({ answers: [] }) }) const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) @@ -996,9 +1004,11 @@ describe('exit_plan_mode', () => { it('reads a dismissed review as the user taking the turn back, not as a failure', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ - ask: () => Promise.reject(new UserQuestionError( - 'the user cancelled ask_user_question', 'ASK_CANCELLED')), + registerQuestionAnswerer(ctx, { + ask: () => Promise.reject(Object.assign( + new Error('the user cancelled ask_user_question'), + { name: 'UserQuestionError', code: 'ASK_CANCELLED' }, + )), }) const result = await callExit(ctx, agent) expect(result.isError).toBe(true) @@ -1008,7 +1018,7 @@ describe('exit_plan_mode', () => { it('leaves every other review failure its own message', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: () => Promise.reject(new UserQuestionError( 'ask_user_question was aborted before the user answered', 'ASK_ABORTED')), }) @@ -1040,7 +1050,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { ask: () => new Promise((resolve) => { answer = resolve }), }) const agent = await agentWithSession(ctx, 'agent-1', { active: true }) @@ -1059,7 +1069,7 @@ describe('exit_plan_mode', () => { it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => { const { ctx, agent } = await setupWithReview() - ctx.userQuestions.registerProvider({ ask: () => { throw new Error('review aborted') } }) + registerQuestionAnswerer(ctx, { ask: () => { throw new Error('review aborted') } }) const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }]) diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 0d5988c0e1..8970f18b9b 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -24,9 +24,10 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" }, @@ -35,9 +36,11 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", @@ -50,9 +53,10 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-commands", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session" ], "platform": "web" } diff --git a/packages/session-query/session-log-export/src/client/Dialog.tsx b/packages/session-query/session-log-export/src/client/Dialog.tsx index c2e1b47528..dc51918c61 100644 --- a/packages/session-query/session-log-export/src/client/Dialog.tsx +++ b/packages/session-query/session-log-export/src/client/Dialog.tsx @@ -1,4 +1,5 @@ -import type { ObservableSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionLogDownloadState } from './controller.ts' diff --git a/packages/session-query/session-log-export/src/client/controller.ts b/packages/session-query/session-log-export/src/client/controller.ts index 4423d19f4e..f7f01cceb9 100644 --- a/packages/session-query/session-log-export/src/client/controller.ts +++ b/packages/session-query/session-log-export/src/client/controller.ts @@ -1,6 +1,7 @@ /** Browser download state shared by the Session Header button and `/export`. */ -import { createSnapshotStore, type SessionId, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Download phases presented by the shared modal. */ export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error' diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index b15b306d2c..97187311fa 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -1,9 +1,12 @@ /** Browser plugin owning Session export download state and its shared modal. */ -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-commands/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import { SessionLogDownloadController } from './controller.ts' import type { SessionLogDownloadDialogInjected } from './Dialog.tsx' import { SessionLogDownloadHeaderAction } from './HeaderAction.tsx' diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index 7e54a4c773..1d669e298e 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' diff --git a/packages/session-query/session-log-export/tests/controller.client.spec.ts b/packages/session-query/session-log-export/tests/controller.client.spec.ts index 8b982a7474..a5fc95852f 100644 --- a/packages/session-query/session-log-export/tests/controller.client.spec.ts +++ b/packages/session-query/session-log-export/tests/controller.client.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { downloadUrl, SessionLogDownloadController, sessionLogZipFilename, } from '../src/client/controller.ts' diff --git a/packages/session-query/session-log-export/tests/dialog.client.spec.tsx b/packages/session-query/session-log-export/tests/dialog.client.spec.tsx index 6a70586ae2..fef9adbbdf 100644 --- a/packages/session-query/session-log-export/tests/dialog.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/dialog.client.spec.tsx @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useSyncExternalStore } from 'react' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionLogDownloadDialog } from '../src/client/Dialog.tsx' import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx' diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index 775d892c6a..f428e3ada3 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useSyncExternalStore } from 'react' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx' diff --git a/packages/session-query/session-log-export/tsconfig.json b/packages/session-query/session-log-export/tsconfig.json index ed1f6f6dd8..46ad790e94 100644 --- a/packages/session-query/session-log-export/tsconfig.json +++ b/packages/session-query/session-log-export/tsconfig.json @@ -11,10 +11,13 @@ { "path": "../../../vendor/cordis" }, { "path": "../../interaction/commands" }, { "path": "../../client/locale" }, - { "path": "../../client/runtime" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, { "path": "../../client/ui-commands" }, { "path": "../../client/ui-conversation" }, { "path": "../../client/ui-primitives" }, + { "path": "../../client/ui-renderer" }, + { "path": "../../client/ui-session" }, { "path": "../../client/ui-slots" }, { "path": "../../runtime-diagnostics/invariants" } ] diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 68d95d36f6..89d356df02 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -96,7 +96,7 @@ export type ProjectionChangeListener = ( /** * One consistent read cut over every registered client-visible unit for one session. * `asOfSeq` is the shared watermark — the seq of the last event every value - * reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`). + * reflects (`-1` for an empty log). */ export interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index d17ac65b2a..4caac8148b 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md -README.md: 443cadb63d2e290874d7248d7fb104c7340d55c7 -README.zh.md: 596f6a90f1223a6108fd09b66c1451539c8ce1fd +README.md: 0254ade4f174d88f979d44019a4179d8a40fc0ca +README.zh.md: 8d90784cf6f6bc8d10dbba4a1ede3a845ed5330e diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 443cadb63d..0254ade4f1 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotRegistry` and UI renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic. +jsdom slot test runtime for client feature specs: a real Cordis `Context`, the renderer-owned `SlotRegistry`, and the production `UiSession` adapter assembled around typed Session and Workspace Controller doubles. Feature suites exercise declaration, registration, scope, store, injection, rendering, updates, and disposal without copying production renderer or adapter logic. -The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`; `stubSettingsScope` is a `SettingsScope` with test-driven publications and a write spy), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionRuntime`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face. +The doubles implement the owner interfaces consumed through Cordis: `TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`, each fixture Session is a `FixtureSession implements SessionFace`, and `stubSettingsScope` implements `SettingsScope`. The runtime mounts `UiSession`, which derives renderer standard sources from Controller bindings. Fixtures publish Session lifecycle state through `updateSessionSnapshot`, Workspace state through `TestWorkspaces.update`, projection values through the Session face, and Conversation input through the Session event feed. Unstubbed `ISession` behavior fails with the missing method name. Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `

` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis. @@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Every consumer is an in-repository Vitest suite; there is no Node-compatible runtime entry. -- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production projection would never produce. +- **Vitest and jsdom only.** Every consumer is an in-repository browser-oriented Vitest suite. The package is not a product plugin or a general Node test harness. +- **Session, Conversation, and Chat fixtures stay separate.** `sessionSnapshot` contains only Session Controller state, `conversationSnapshot` contains target-neutral Conversation state, and `chatSnapshot` contains Chat target state. Tests that exercise assembly provide Session event entries instead of adding Conversation or Chat fields to `SessionSnapshot`. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 596f6a90f1..8d90784cf6 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -面向客户端功能测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotRegistry` 与 UI 渲染器,围绕带类型的 session/workspace 测试替身组装。功能套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。 +面向客户端功能测试的 jsdom slot 测试运行时:真实 Cordis `Context`、renderer 所有的 `SlotRegistry` 与生产 `UiSession` adapter,围绕带类型的 Session 和 Workspace Controller 测试替身组装。功能套件无需复制生产 renderer 或 adapter 逻辑,即可测试声明、注册、scope、store、注入、渲染、更新与销毁。 -替身实现的正是功能通过 ctx 获得的对外接口(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`;`stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionRuntime` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。 +替身实现通过 Cordis 消费的 owner 接口:`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`,每个 fixture Session 是 `FixtureSession implements SessionFace`,`stubSettingsScope` 实现 `SettingsScope`。运行时挂载 `UiSession`,由它从 Controller binding 派生 renderer 标准 source。fixture 通过 `updateSessionSnapshot` 发布 Session 生命周期状态,通过 `TestWorkspaces.update` 发布 Workspace 状态,通过 Session face 发布 projection 值,并通过 Session event feed 提供 Conversation 输入。未打桩的 `ISession` 行为会在错误中指出缺失方法。 局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `
` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。 @@ -20,5 +20,5 @@ ## 已知限制与延期工作 -- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。所有消费方都是仓内 Vitest 套件;不存在 Node 兼容的运行时入口。 -- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。 +- **仅用于 Vitest 与 jsdom。** 所有消费者都是仓内、面向浏览器的 Vitest 套件。本包不是产品插件,也不是通用 Node 测试框架。 +- **Session、Conversation 与 Chat fixture 相互分离。** `sessionSnapshot` 只包含 Session Controller 状态,`conversationSnapshot` 包含目标无关的 Conversation 状态,`chatSnapshot` 包含 Chat 目标状态。测试装配过程时应提供 Session event entry,不得向 `SessionSnapshot` 添加 Conversation 或 Chat 字段。 diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index fb39c32177..84613f30ac 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -32,21 +32,37 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/test-support/client-runtime/src/fixtures.ts b/packages/test-support/client-runtime/src/fixtures.ts index 396e3a0267..dc0ff57744 100644 --- a/packages/test-support/client-runtime/src/fixtures.ts +++ b/packages/test-support/client-runtime/src/fixtures.ts @@ -1,10 +1,18 @@ -/** Session/workspace fixture shapes and snapshot defaults for the test runtime. */ +/** Controller and UI-domain fixture shapes for the client test runtime. */ import type { - ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState, -} from '@deepseek-ai/dsh-client-runtime/client' + ISession, SessionSnapshot, SessionSummary, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, -} from '@deepseek-ai/dsh-client-runtime/client' + EMPTY_CONVERSATION_SNAPSHOT, + type ConversationSnapshot, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { + EMPTY_CHAT_SNAPSHOT, + type ChatSnapshot, +} from '@deepseek-ai/dsh-client-ui-chat/client' /** * Fixture overrides for the session behavior face: any subset of the @@ -24,6 +32,12 @@ export type SessionBehaviorOverrides = Partial & Record void | Promise) => Promise +/** Mutable top-level snapshot fields accepted by fixture update callbacks. */ +export type FixtureSnapshot = { -readonly [Key in keyof T]: T[Key] } + +/** Writable test representation of the immutable Session Controller snapshot. */ +export type SessionFixtureSnapshot = FixtureSnapshot + /** * Session fixture accepted by {@link TestSessions.add}: identity plus optional * snapshot/list-row overrides and the session behavior face the feature under @@ -32,34 +46,29 @@ export type Stabilizer = (fn: () => void | Promise) => Promise */ export interface SessionFixture { id: string - /** Overrides merged over {@link conversationSnapshot} (sessionId comes from `id`). */ - snapshot?: Partial> + /** Overrides merged over {@link sessionSnapshot}; Conversation data arrives through the event feed. */ + snapshot?: Partial> /** List-row overrides merged over the defaults derived from `id`. */ summary?: Partial> /** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */ session?: SessionBehaviorOverrides + /** Initial contiguous event window consumed by Conversation assembly. */ + events?: readonly SessionEventEntry[] + /** Whether the initial event window has an older page. */ + hasMore?: boolean } /** - * A complete quiescent conversation snapshot (open window, no traffic). + * A complete quiescent Session Controller snapshot. * @param sessionId - owning session id. * @returns the snapshot; spread fixture overrides on top. */ -export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot { +export function sessionSnapshot(sessionId: SessionId): SessionSnapshot { return { sessionId, - views: EMPTY_CONVERSATION_VIEWS, - chat: EMPTY_CHAT_SNAPSHOT, - nodes: [], - turnTimings: new Map(), - turnEnds: new Map(), - partial: null, - runningCalls: [], - pending: [], queue: [], running: false, subagent: null, - composerPhase: 'active', removed: false, openState: 'open', openError: null, @@ -68,22 +77,41 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot promptError: null, blank: false, lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, } } /** - * A ready workspace list with no workspaces (the shape WorkspaceRuntime - * projects after both baselines land). - * @returns the initial state of the test workspaces store. + * A target-neutral Conversation snapshot. + * @param overrides - target roster or activity overrides. + * @returns an immutable fixture value. */ -export function workspaceListState(): WorkspaceListState { +export function conversationSnapshot( + overrides: Partial = {}, +): ConversationSnapshot { + return { ...EMPTY_CONVERSATION_SNAPSHOT, ...overrides } +} + +/** + * A Chat target snapshot. + * @param overrides - Chat target overrides. + * @returns an immutable fixture value. + */ +export function chatSnapshot(overrides: Partial = {}): ChatSnapshot { + return { ...EMPTY_CHAT_SNAPSHOT, ...overrides } +} + +/** + * A ready Workspace Controller snapshot with no Workspace rows. + * @returns the initial state of the test Workspace source. + */ +export function workspaceSnapshot(): WorkspaceSnapshot { return { items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, - baselinesReady: true, - recentWorkspaceId: undefined, } } diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 37aebf66ee..4c19296f65 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -1,6 +1,6 @@ /** * jsdom slot test runtime: a real small runtime — Cordis `Context`, the - * runtime `SlotRegistry`, and the UI renderer — assembled around + * renderer-owned `SlotRegistry`, the `ui-session` adapter, and the UI renderer — assembled around * test-owned session/workspace doubles, so feature specs exercise * declaration, registration, scope, store, inject, rendering, updates, and * disposal without hand-building the machinery per suite. @@ -22,11 +22,12 @@ import { act, render, within } from '@testing-library/react' import type { RenderResult } from '@testing-library/react' import type { queries } from '@testing-library/dom' import type { BoundFunctions } from '@testing-library/dom' -import { - ConversationEventRegistry, ConversationViewRegistry, SlotRegistry, -} from '@deepseek-ai/dsh-client-runtime/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { bindSnapshotSelector as bindRendererSnapshotSelector } from '@deepseek-ai/dsh-client-ui-renderer/src/client/bind.ts' import { createSlotRenderer as createRenderer } from '@deepseek-ai/dsh-client-ui-renderer/src/client/scoped-slots.tsx' +import { + apply as applyUiSession, inject as uiSessionInject, +} from '@deepseek-ai/dsh-client-ui-session/client' import type { ChildrenDecl, ComposedProps, HostObservable, OwnerOf, SlotComponent, SlotMap, SlotRenderer, SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike, @@ -36,15 +37,19 @@ import { TestSessions } from './sessions.ts' import { TestWorkspaces } from './workspaces.ts' import type { Stabilizer } from './fixtures.ts' -export type { UseSession } from '@deepseek-ai/dsh-client-ui-renderer/client' +export type { UseSession } from '@deepseek-ai/dsh-client-ui-session/client' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' export { stubSettingsScope } from './settings-scope.ts' export type { StubSettingsScope } from './settings-scope.ts' export { TestWorkspaces } from './workspaces.ts' export { TestRemote } from './remote.ts' -export { conversationSnapshot, workspaceListState } from './fixtures.ts' -export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' +export { + chatSnapshot, conversationSnapshot, sessionSnapshot, workspaceSnapshot, +} from './fixtures.ts' +export type { + FixtureSnapshot, SessionBehaviorOverrides, SessionFixture, SessionFixtureSnapshot, Stabilizer, +} from './fixtures.ts' export { makeTranslate } from './translate.ts' export { usePinnedBrowserLanguages } from './locale-env.ts' @@ -191,7 +196,7 @@ export class TestRoot { * batching or React act themselves. */ export class SlotTestRuntime { - /** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */ + /** The runtime's Cordis root for owner APIs and explicit test-only services. */ readonly ctx: Context /** The production SlotRegistry mounted on {@link SlotTestRuntime.ctx}. */ readonly slots: SlotRegistry @@ -214,6 +219,7 @@ export class SlotTestRuntime { private readonly ownerCell = new OwnerPropsCell() private readonly autoDeclared = new Set() private autoRootView: RenderResult | undefined + private readonly disposeWorkspaceSource: () => void private constructor(ctx: Context, slots: SlotRegistry) { this.ctx = ctx @@ -223,6 +229,7 @@ export class SlotTestRuntime { this.workspaces = new TestWorkspaces(this.stabilizer) ctx.provide('sessions', this.sessions) ctx.provide('workspaces', this.workspaces) + this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } }) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). const renderer = createSlotRenderer() @@ -244,23 +251,9 @@ export class SlotTestRuntime { const ctx = new Context() const fiber = ctx.plugin(SlotRegistry) await fiber.await() - await ctx.plugin(ConversationEventRegistry).await() - await ctx.plugin(ConversationViewRegistry).await() - return new SlotTestRuntime(ctx, ctx.get('slots') as SlotRegistry) - } - - /** - * Provide an extra service the feature under test injects (e.g. a layout - * fake). Sugar over `ctx.provide`, typed against the Context declaration - * merge: for a declared service name the fake must be a subset of that - * service's outward face (Partial — supply only what the feature calls), - * so a production face change breaks the fake at compile time. Undeclared - * names stay unchecked (ad-hoc test services). - * @param name - service name. - * @param value - service implementation (test double). - */ - provide(name: K, value: K extends keyof Context ? Partial : unknown): void { - this.ctx.provide(name, value) + const runtime = new SlotTestRuntime(ctx, ctx.get('slots') as SlotRegistry) + await ctx.plugin({ inject: [...uiSessionInject], apply: applyUiSession }).await() + return runtime } /** @@ -293,6 +286,11 @@ export class SlotTestRuntime { return handle } + /** Release the default Workspace hook before mounting its production owner. */ + releaseWorkspaceSource(): void { + this.disposeWorkspaceSource() + } + /** * Render the root slot tree through the ctx-level entry (the shell's own * entry point): `ctx.slots.renderSlot('root', {})` under Testing Library. @@ -372,7 +370,13 @@ export class SlotTestRuntime { } const entry = this.host.entriesOf(key)[0] if (entry === undefined) throw new Error(`storeOf('${key}'): no registration on the ledger`) - const instance = this.host.storeOf(entry, scopeKey) + const scopeBinding = scopeKey === undefined + ? undefined + : this.host.scope('session')?.resolve(scopeKey) + if (scopeKey !== undefined && scopeBinding === undefined) { + throw new Error(`storeOf('${key}'): no live Session binding for '${scopeKey}'`) + } + const instance = this.host.storeOf(entry, scopeBinding) if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`) return instance } diff --git a/packages/test-support/client-runtime/src/remote.ts b/packages/test-support/client-runtime/src/remote.ts index fab7f9b1fd..d90baf7e0f 100644 --- a/packages/test-support/client-runtime/src/remote.ts +++ b/packages/test-support/client-runtime/src/remote.ts @@ -1,16 +1,12 @@ -/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */ +/** Test-owned Remote face: `$on` subscriptions with an explicit test event driver. */ import type { Context } from '@deepseek-ai/cordis' /** * Remote service test double for the forwarded-event path. Feature specs need * `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded - * host events to reach those subscribers, but not the generated namespaces or - * the wire — so this double implements subscription and dispatch only. - * - * Dispatch is driven the same way production drives it: `client/runtime` owns the - * host frame sink and hands each decoded `host/remote-event` frame to - * `$dispatch`. A spec therefore exercises its refresh chains by calling - * `$dispatch(name, args)` on this double. + * Host events to reach those subscribers, but not the generated namespaces or + * the wire — so this double implements subscription plus an explicit `emit` + * driver available only on the concrete test object. * * `$mount` rejects: a spec that reaches a generated namespace through this * double has outgrown it and needs the real Client Remote service. @@ -37,7 +33,7 @@ export class TestRemote { * @param event - forwarded host event name. * @param args - the Host argument list, verbatim. */ - $dispatch(event: string, args: readonly unknown[]): void { + emit(event: string, args: readonly unknown[]): void { const listeners = this.subscriptions.get(event) if (listeners === undefined) return for (const listener of [...listeners]) listener(...args as never[]) diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index c014d6f002..afb24fa287 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -1,42 +1,47 @@ -/** Test-owned sessions face: the SlotRegistry host contract over declarative fixtures. */ +/** Test-owned Session Controller faces over declarative fixtures. */ import type { Context } from '@deepseek-ai/cordis' import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment' -import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createScope, MutableSessionEventSource, scopeOf, SESSION_SEARCH_RESULT_LIMIT, +} from '@deepseek-ai/dsh-api-session-controller/client' import type { - AgentContext, ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, - SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore, - SubagentAddress, -} from '@deepseek-ai/dsh-client-runtime/client' -// The double reports the wire schema's own search bound, like the production -// service — a transport-varying limit would be a fiction no client can see. -import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' -import { conversationSnapshot } from './fixtures.ts' -import type { SessionFixture, Stabilizer } from './fixtures.ts' + AgentContext, ISessions, ProjectionsFace, SessionBinding, SessionFace, SessionListState, + SessionSearchResultItem, SessionSnapshot, SessionSummary, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { sessionSnapshot } from './fixtures.ts' +import type { + SessionFixture, SessionFixtureSnapshot, Stabilizer, +} from './fixtures.ts' /** - * The fixture-backed session face: conversation reads delegate to the - * fixture's snapshot store; ISession verbs are fail-loud stubs unless the + * The fixture-backed session face: lifecycle reads delegate to the fixture's + * snapshot store; Session verbs are fail-loud stubs unless the * fixture supplies them (the runtime never fakes behavior a test did not * declare — an unstubbed call names itself instead of half-working). Extra * fixture methods are grafted verbatim for feature-side casts. */ export class FixtureSession implements SessionFace { + /** Mutable event source consumed only by Conversation assembly. */ + readonly eventSource = new MutableSessionEventSource() + /** - * The useProjection seat: identity-stable per-key faces over the fixture's - * projection values (set via {@link TestSessions.setProjection}). + * Identity-stable per-key faces over fixture-controlled projection values. */ readonly projections: ProjectionsFace & { set(key: string, value: unknown): void } /** * @param sessionId - host identity (branded view of the fixture id). - * @param store - conversation snapshot store (updateSnapshot writes it). + * @param store - Session Controller snapshot store. * @param overrides - fixture-declared behavior face, grafted over the stubs. */ constructor( readonly sessionId: SessionId, - private readonly store: SnapshotStore, + private readonly store: SnapshotStore, overrides: Record, ) { const values = new Map() @@ -67,8 +72,8 @@ export class FixtureSession implements SessionFace { Object.assign(this, overrides) } - /** @returns the fixture conversation snapshot (useSession read side). */ - getSnapshot(): ConversationSnapshot { + /** @returns the fixture Session Controller snapshot (useSession read side). */ + getSnapshot(): SessionSnapshot { return this.store.getSnapshot() } @@ -142,50 +147,33 @@ export class FixtureSession implements SessionFace { /** One live test session: fixture-derived stores plus its minted scope state. */ interface SessionRecord { summary: SessionSummary - snapshot: SnapshotStore + snapshot: SnapshotStore session: FixtureSession scope: AgentContext | undefined scopeFiber: { dispose(): Promise } | undefined - /** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */ - provideInfo: SessionProvideInfo | undefined -} - -/** Test binding shape handed to provider resolvers and feature injects (a SessionBinding whose session is the fixture face). */ -export interface TestSessionBinding { - readonly sessionId: SessionId - readonly session: FixtureSession - readonly ctx: AgentContext + binding: SessionBinding | undefined } /** * Sessions test double behind the renderer host and feature injects: owns the - * list/current observable, the standard-props provide channel (the runtime's - * `useSession` contribution included), scope minting through the production - * `createScope`, and the session behavior face supplied per fixture. + * list/current observable, scope minting through the production `createScope`, + * stable Controller bindings, and the session behavior face supplied per + * fixture. `ui-session` owns standard-source materialization. * * Implements the same ISessions face features receive as `ctx.sessions`, so * a production face change breaks this double at compile time; the extra - * members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and - * the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface. + * members (add/updateSessionSnapshot/event-window drivers/setCurrent/remove/ + * behavior/calls/stubs) are bench-only surface. */ export class TestSessions implements ISessions { /** The useSessions standard feed (list rows + current selection). */ readonly list: SnapshotStore - /** - * Atomic current-session provide projection (production SessionRuntime - * mirror): selection changes and provider-roster changes publish through - * this one source — the member the SlotRegistry host face hands the - * renderer's SessionProvider. - */ - readonly currentProvideInfo: HostObservable private readonly records = new Map() - /** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */ - private readonly channel: SessionProvideChannel /** Calls observed on the service-level face, newest last. */ readonly calls: { - method: 'open' | 'openSubagent' | 'setSubagentCatalogOpen' | 'refreshSubagents' - | 'clear' | 'search' | 'fork' + method: 'create' | 'open' | 'openSubagent' | 'setSubagentCatalogOpen' | 'refreshSubagents' + | 'clear' | 'refresh' | 'search' | 'fork' args: unknown[] }[] = [] @@ -194,6 +182,7 @@ export class TestSessions implements ISessions { /** Replaceable search behavior (see {@link TestSessions.stubSearch}). */ private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined + private createStub: ((opts: Parameters[0]) => Promise) | undefined /** * @param stabilize - the owning runtime's act wrapper. @@ -204,19 +193,6 @@ export class TestSessions implements ISessions { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, }) - this.channel = new SessionProvideChannel({ - rebuildBundles: () => { - for (const record of this.records.values()) { - if (record.provideInfo !== undefined) { - record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record)) - } - } - }, - resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current), - }) - this.currentProvideInfo = this.channel.currentProvideInfo - // The projection follows every current write, as in production. - this.list.subscribe(() => { this.channel.publishCurrent() }) } /** @@ -236,17 +212,21 @@ export class TestSessions implements ISessions { updatedAt: this.records.size + 1, ...fixture.summary, } - const snapshot = createSnapshotStore({ - ...conversationSnapshot(id), + const snapshot = createSnapshotStore({ + ...sessionSnapshot(id), ...fixture.snapshot, }) + const session = new FixtureSession(id, snapshot, fixture.session ?? {}) + if (fixture.events !== undefined || fixture.hasMore === true) { + session.eventSource.replace(fixture.events ?? [], fixture.hasMore ?? false) + } this.records.set(id, { summary, snapshot, - session: new FixtureSession(id, snapshot, fixture.session ?? {}), + session, scope: undefined, scopeFiber: undefined, - provideInfo: undefined, + binding: undefined, }) await this.stabilize(() => { this.list.update((draft) => { @@ -259,16 +239,55 @@ export class TestSessions implements ISessions { } /** - * Update a session's conversation snapshot through an immer draft (the - * live-stream stand-in: components subscribed via useSession re-render). + * Update Session Controller lifecycle state through an immer draft. * @param id - session id. * @param mutate - draft mutator. */ - async updateSnapshot(id: string, mutate: (draft: ConversationSnapshot) => void): Promise { + async updateSessionSnapshot( + id: string, + mutate: (draft: SessionFixtureSnapshot) => void, + ): Promise { const record = this.require(id) await this.stabilize(() => { record.snapshot.update(mutate) }) } + /** + * Replace a Session's complete contiguous event window. + * @param id - Session identity. + * @param entries - complete event window. + * @param hasMore - whether older history remains. + */ + async replaceEvents( + id: string, + entries: readonly SessionEventEntry[], + hasMore = false, + ): Promise { + await this.stabilize(() => { this.require(id).session.eventSource.replace(entries, hasMore) }) + } + + /** + * Prepend one older contiguous event page. + * @param id - Session identity. + * @param entries - older entries. + * @param hasMore - whether another older page remains. + */ + async prependEvents( + id: string, + entries: readonly SessionEventEntry[], + hasMore = false, + ): Promise { + await this.stabilize(() => { this.require(id).session.eventSource.prepend(entries, hasMore) }) + } + + /** + * Append one live event to a Session's contiguous window. + * @param id - Session identity. + * @param entry - live event entry. + */ + async appendEvent(id: string, entry: SessionEventEntry): Promise { + await this.stabilize(() => { this.require(id).session.eventSource.append(entry) }) + } + /** * Update a session's list row (the wire-echo stand-in: title settles, * running flips — components subscribed via useSessions re-render). @@ -297,7 +316,7 @@ export class TestSessions implements ISessions { /** * Remove a session: list row, scope fiber, and per-session store instances * (with persisted state) die together — the same single lifecycle axis the - * production SessionRuntime drives on session death, minus staging. + * production Client Sessions service drives on session death, minus staging. * @param id - session id. */ async remove(id: string): Promise { @@ -311,43 +330,9 @@ export class TestSessions implements ISessions { if (draft.current === id) draft.current = undefined }) if (record.scopeFiber !== undefined) await record.scopeFiber.dispose() - this.rootCtx.get('slots')?.pruneStoreScope(id) }) } - /** - * Register a per-session standard-props provider (production `provide` - * contract: hooks become `use` selector hooks on the render side, - * props spread verbatim; duplicate names fail loud at materialization). - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider. - */ - provide(descriptor: SessionProvideDescriptor): () => void { - return this.channel.provide(descriptor) - } - - /** - * Resolve the definite per-session standard-props bundle (host face member). - * @param id - session id. - * @returns the identity-stable bundle, or undefined for unknown sessions. - */ - provideInfo(id: string): SessionProvideInfo | undefined { - const record = this.records.get(id as SessionId) - if (record === undefined) return undefined - record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id as SessionId, record)) - return record.provideInfo - } - - /** - * Resolve the current-session-optional standard kit (host face member): - * unknown or absent ids return the static no-session projection. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. - */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { - return (id === undefined ? undefined : this.provideInfo(id)) ?? this.channel.maybeInfo - } - /** * Resolve (mint on first touch) the session-scoped Cordis context through * the production `createScope`, so real `scopeOf`/scope-addressed services @@ -371,10 +356,11 @@ export class TestSessions implements ISessions { * @param id - session id. * @returns sessionId + behavior face + scoped ctx, or undefined when unknown. */ - binding(id: string): TestSessionBinding | undefined { + binding(id: string): SessionBinding | undefined { const record = this.records.get(id as SessionId) if (record === undefined) return undefined - return this.bindingOf(id as SessionId, record) + record.binding ??= this.bindingOf(id as SessionId, record) + return record.binding } /** @@ -398,6 +384,25 @@ export class TestSessions implements ISessions { return this.records.get(id)?.session } + /** + * Install Session creation behavior for navigation tests. + * @param impl - implementation that must return an already-added fixture id. + */ + stubCreate(impl: (opts: Parameters[0]) => Promise): void { + this.createStub = impl + } + + /** Create through the installed test behavior and require an addressable binding. */ + async create(opts?: Parameters[0]): Promise { + this.calls.push({ method: 'create', args: [opts] }) + if (this.createStub === undefined) { + throw new Error('test sessions: create is not stubbed — call stubCreate() first') + } + const id = await this.createStub(opts) + this.require(id) + return id + } + /** * Service-level selection call (recorded, then applied to the list store * synchronously — inject callbacks call this outside any act window; the @@ -457,6 +462,12 @@ export class TestSessions implements ISessions { }) } + /** Record a list refresh; fixture callers publish list state explicitly. */ + refresh(): Promise { + this.calls.push({ method: 'refresh', args: [] }) + return Promise.resolve() + } + /** * Replace the sidebar-search result page (the call is still recorded). * @param impl - hits for a query, as the Host would rank them. @@ -493,7 +504,7 @@ export class TestSessions implements ISessions { * The session face of a fixture (typed view for assertions; fixture * behavior methods are grafted onto it). * @param id - session id. - * @returns the FixtureSession the binding and provide channel carry. + * @returns the FixtureSession carried by the Controller binding. */ behavior(id: string): FixtureSession { return this.require(id).session @@ -506,16 +517,22 @@ export class TestSessions implements ISessions { await record.scopeFiber.dispose() record.scope = undefined record.scopeFiber = undefined + record.binding = undefined } } } - private bindingOf(id: SessionId, record: SessionRecord): TestSessionBinding { + private bindingOf(id: SessionId, record: SessionRecord): SessionBinding { const ctx = this.scope(id) /* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope * always resolves; kept so a future caller cannot mint a ctx-less binding. */ if (ctx === undefined) throw new Error(`test session "${id}" resolved no scope`) - return { sessionId: id, session: record.session, ctx } + return { + sessionId: id, + session: record.session, + eventSource: record.session.eventSource, + ctx, + } } private require(id: string): SessionRecord { @@ -523,4 +540,5 @@ export class TestSessions implements ISessions { if (record === undefined) throw new Error(`test session "${id}" is not added`) return record } + } diff --git a/packages/test-support/client-runtime/src/settings-scope.ts b/packages/test-support/client-runtime/src/settings-scope.ts index 377ed84210..86566bb33a 100644 --- a/packages/test-support/client-runtime/src/settings-scope.ts +++ b/packages/test-support/client-runtime/src/settings-scope.ts @@ -1,6 +1,8 @@ /** Test double for the client settings-scope seam. */ import { vi } from 'vitest' -import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { + SettingsScope, SettingsScopeSnapshot, +} from '@deepseek-ai/dsh-client-ui-settings/client' /** Handle over one stubbed scope: the scope, its write spy, and publication controls. */ export interface StubSettingsScope { diff --git a/packages/test-support/client-runtime/src/workspaces.ts b/packages/test-support/client-runtime/src/workspaces.ts index 4f6b2122cb..60e072af2a 100644 --- a/packages/test-support/client-runtime/src/workspaces.ts +++ b/packages/test-support/client-runtime/src/workspaces.ts @@ -1,10 +1,25 @@ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' -import { workspaceListState } from './fixtures.ts' -import type { Stabilizer } from './fixtures.ts' + IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, +} from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { workspaceSnapshot } from './fixtures.ts' +import type { FixtureSnapshot, Stabilizer } from './fixtures.ts' + +/** Writable test representation of the immutable Workspace Controller snapshot. */ +type WorkspaceFixtureSnapshot = FixtureSnapshot + +/** Callable command names on the production Workspace Controller face. */ +type WorkspaceAction = { + [Key in keyof IWorkspaces]: IWorkspaces[Key] extends (...args: never[]) => unknown ? Key : never +}[keyof IWorkspaces] + +/** Test replacement retaining one Controller command's parameters and result. */ +type WorkspaceStub = ( + ...args: Parameters +) => ReturnType /** * Workspaces test double. Implements the same IWorkspaces face features @@ -15,59 +30,36 @@ import type { Stabilizer } from './fixtures.ts' */ export class TestWorkspaces implements IWorkspaces { /** The useWorkspaces standard feed. */ - readonly list: SnapshotStore + readonly list: SnapshotStore /** Calls observed on the action face, newest last. */ readonly calls: { method: string; args: unknown[] }[] = [] /** Replaceable action seat: feature tests may stub richer behavior. */ - private readonly stubs = new Map unknown>() + private readonly stubs = new Map unknown>() /** * @param stabilize - the owning runtime's act wrapper. */ constructor(private readonly stabilize: Stabilizer) { - this.list = createSnapshotStore(workspaceListState()) + this.list = createSnapshotStore({ ...workspaceSnapshot() }) } /** * Update the workspace list state through an immer draft. * @param mutate - draft mutator. */ - async update(mutate: (draft: WorkspaceListState) => void): Promise { + async update(mutate: (draft: WorkspaceFixtureSnapshot) => void): Promise { await this.stabilize(() => { this.list.update(mutate) }) } /** * Replace an action's behavior (the recorded call is still appended first). - * @param method - action name (e.g. 'connectWorkspace'). + * @param method - Controller action name (e.g. 'create'). * @param impl - replacement behavior. */ - stub(method: string, impl: (...args: unknown[]) => unknown): void { - this.stubs.set(method, impl) - } - - /** - * Connect a workspace to its reusable/new blank session (recorded). The - * default resolves the workspace id back as the session id; stub for - * cross-session flows. - * @param workspaceId - target workspace. - * @returns the connected session id. - */ - async connectWorkspace(workspaceId: WorkspaceId): Promise { - this.calls.push({ method: 'connectWorkspace', args: [workspaceId] }) - const stub = this.stubs.get('connectWorkspace') - if (stub !== undefined) return await (stub(workspaceId) as Promise) - return `session-of-${workspaceId}` as SessionId - } - - /** - * New-session flow (recorded; stubbed behavior runs when installed). - * @param workspaceId - optional explicit workspace target. - */ - startSession(workspaceId?: WorkspaceId): void { - this.calls.push({ method: 'startSession', args: [workspaceId] }) - this.stubs.get('startSession')?.(workspaceId) + stub(method: Key, impl: WorkspaceStub): void { + this.stubs.set(method, impl as (...args: unknown[]) => unknown) } /** @@ -88,68 +80,6 @@ export class TestWorkspaces implements IWorkspaces { } as unknown as WorkspaceView } - /** - * Open a path with the host OS default application (recorded; default no-op). - * @param path - host-resolvable path. - */ - async openPath(path: string): Promise { - this.calls.push({ method: 'openPath', args: [path] }) - await (this.stubs.get('openPath')?.(path) as Promise | undefined) - } - - /** - * Directory picker (recorded). The default cancels (null); stub to select. - * @returns the picked path, or null. - */ - async pickDirectory(): Promise { - this.calls.push({ method: 'pickDirectory', args: [] }) - const stub = this.stubs.get('pickDirectory') - if (stub !== undefined) return await (stub() as Promise) - return null - } - - /** - * Browse listing (recorded). The default serves an empty home level; stub - * to shape a tree. - * @param path - absolute directory to list; absent lists the home level. - * @returns the level's listing. - */ - async listDirectory(path?: string, signal?: AbortSignal): Promise { - // The signal is recorded and forwarded like the production face passes - // it to the wire, so cancellation integration tests can observe or - // reject on a superseded scan. - this.calls.push({ method: 'listDirectory', args: [path, signal] }) - const stub = this.stubs.get('listDirectory') - if (stub !== undefined) return await (stub(path, signal) as Promise) - // The chain runs root-to-target inclusive, per the DirectoryListing - // contract — a bare root crumb would mislabel the level in browsers - // driven by this double. - return { - path: '/home/test', - home: '/home/test', - crumbs: [ - { name: '/', path: '/', hidden: false }, - { name: 'home', path: '/home', hidden: false }, - { name: 'test', path: '/home/test', hidden: false }, - ], - entries: [], - truncated: false, - } - } - - /** - * Browse child creation (recorded). The default joins parent and name. - * @param path - absolute existing parent directory. - * @param name - single path segment. - * @returns the created directory's absolute path. - */ - async createDirectory(path: string, name: string): Promise { - this.calls.push({ method: 'createDirectory', args: [path, name] }) - const stub = this.stubs.get('createDirectory') - if (stub !== undefined) return await (stub(path, name) as Promise) - return `${path}/${name}` - } - /** * Rename a Workspace (recorded). The default echoes a minimal view. * @param workspaceId - target workspace. diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx new file mode 100644 index 0000000000..58d639864d --- /dev/null +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react' +import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client' +import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { + bindSnapshotSelector, + chatSnapshot, + conversationSnapshot, + SlotTestRuntime, + usePinnedBrowserLanguages, +} from '../src/index.ts' + +const originalLanguages = [...navigator.languages] +const originalLanguage = navigator.language + +usePinnedBrowserLanguages('zh-CN', 'en-US') +afterEach(cleanup) +afterAll(() => { + expect(navigator.languages).toEqual(originalLanguages) + expect(navigator.language).toBe(originalLanguage) +}) + +function entry(seq: number): SessionEventEntry { + return { + event: { + type: 'fixture/event', + seq, + time: seq, + data: { seq }, + ignorable: true, + }, + } +} + +describe('fixture helpers', () => { + it('builds independent Conversation and Chat snapshots with optional overrides', () => { + const conversation = conversationSnapshot() + expect(conversation).toEqual(EMPTY_CONVERSATION_SNAPSHOT) + expect(conversation).not.toBe(EMPTY_CONVERSATION_SNAPSHOT) + const activeTargets = new Set(['chat']) + expect(conversationSnapshot({ activeTargets }).activeTargets).toBe(activeTargets) + + const chat = chatSnapshot() + expect(chat).toEqual(EMPTY_CHAT_SNAPSHOT) + expect(chat).not.toBe(EMPTY_CHAT_SNAPSHOT) + const order = ['node-1'] + expect(chatSnapshot({ order }).order).toBe(order) + }) + + it('binds an observable snapshot through the production selector hook', () => { + const source = createSnapshotStore({ value: 1 }) + const useValue = bindSnapshotSelector(source) + const view = renderHook(() => useValue(snapshot => snapshot.value)) + expect(view.result.current).toBe(1) + + act(() => { source.update((draft) => { draft.value = 2 }) }) + expect(view.result.current).toBe(2) + }) + + it('pins both browser language fields for the calling suite', () => { + expect(navigator.languages).toEqual(['zh-CN', 'en-US']) + expect(navigator.language).toBe('zh-CN') + }) +}) + +describe('Session fixture lifecycle', () => { + it('initializes and drives complete event windows through replace, prepend, and append', async () => { + const runtime = await SlotTestRuntime.create() + const first = entry(1) + const older = entry(0) + const live = entry(2) + + await runtime.sessions.add({ id: 'events', events: [first] }, { current: false }) + expect(runtime.sessions.behavior('events').eventSource.getSnapshot()).toMatchObject({ + entries: [first], + hasMore: false, + change: { kind: 'replace', entries: [first] }, + }) + + await runtime.sessions.add({ id: 'has-more', hasMore: true }, { current: false }) + expect(runtime.sessions.behavior('has-more').eventSource.getSnapshot()).toMatchObject({ + entries: [], + hasMore: true, + }) + + await runtime.sessions.replaceEvents('events', [first]) + await runtime.sessions.prependEvents('events', [older]) + await runtime.sessions.appendEvent('events', live) + expect(runtime.sessions.behavior('events').eventSource.getSnapshot()).toMatchObject({ + entries: [older, first, live], + hasMore: false, + change: { kind: 'append', entries: [live] }, + }) + await runtime.dispose() + }) + + it('requires an explicit create stub and records successful create and refresh calls', async () => { + const runtime = await SlotTestRuntime.create() + await expect(runtime.sessions.create()).rejects.toThrow(/create is not stubbed/) + await runtime.sessions.add({ id: 'created' }, { current: false }) + const create = vi.fn(() => Promise.resolve('created' as SessionId)) + runtime.sessions.stubCreate(create) + + await expect(runtime.sessions.create({ cwd: '/workspace' })).resolves.toBe('created') + await expect(runtime.sessions.refresh()).resolves.toBeUndefined() + expect(create).toHaveBeenCalledWith({ cwd: '/workspace' }) + expect(runtime.sessions.calls.slice(-2)).toEqual([ + { method: 'create', args: [{ cwd: '/workspace' }] }, + { method: 'refresh', args: [] }, + ]) + await runtime.dispose() + }) + + it('disposes a scope without materializing a binding', async () => { + const runtime = await SlotTestRuntime.create() + await runtime.sessions.add({ id: 'scope-only' }, { current: false }) + const scope = runtime.sessions.scope('scope-only') + expect(scope).toBeDefined() + const release = vi.fn() + scope?.effect(() => release, 'fixture scope release') + + runtime.releaseWorkspaceSource() + await runtime.dispose() + expect(release).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index 90b1bde56f..df376a83eb 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -16,21 +16,21 @@ describe('TestRemote', () => { seen.push(ns) }) - ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 1]) + remote.emit('settings/document-updated', ['ui-theme', 1]) expect(seen).toEqual(['ui-theme']) off() - ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 2]) + remote.emit('settings/document-updated', ['ui-theme', 2]) expect(seen).toEqual(['ui-theme']) await ctx.fiber.dispose() }) it('drops a forwarded event nobody subscribed to', async () => { const ctx = new Context() - new TestRemote(ctx) + const remote = new TestRemote(ctx) // No subscriber for this name: the emit must be inert rather than throwing, // because the wire carries whatever the Host allowlist selected. - expect(() => { ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY']) }).not.toThrow() + expect(() => { remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) }).not.toThrow() await ctx.fiber.dispose() }) diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index d7f1c3ffc7..f98b93bb0c 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -9,8 +9,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { stubSettingsScope } from '../src/settings-scope.ts' import { cleanup } from '@testing-library/react' -import { defineStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import { defineStore } from '@deepseek-ai/dsh-client-store' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -33,7 +34,7 @@ function Frame({ renderSlot, SessionProvider }: FrameProps) { <> {renderSlot('trt.panel', { label: 'from-owner' }, { fallback: no panel })} no session}> - {() => renderSlot('trt.chat', {})} + {renderSlot('trt.chat', {})} {renderSlot('trt.rows', {})} @@ -93,7 +94,7 @@ describe('sessions', () => { await runtime.sessions.add({ id: 's1' }) expect(view.container.textContent).toContain('chat:s1:false') - await runtime.sessions.updateSnapshot('s1', (draft) => { draft.running = true }) + await runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.running = true }) expect(view.container.textContent).toContain('chat:s1:true') await runtime.sessions.add({ id: 's2' }) // becomes current by default @@ -114,7 +115,7 @@ describe('sessions', () => { expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2']) await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/) await expect(runtime.sessions.setCurrent('ghost')).rejects.toThrow(/not added/) - await expect(runtime.sessions.updateSnapshot('ghost', () => {})).rejects.toThrow(/not added/) + await expect(runtime.sessions.updateSessionSnapshot('ghost', () => {})).rejects.toThrow(/not added/) await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/) expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/) await runtime.dispose() @@ -125,7 +126,6 @@ describe('sessions', () => { const prompt = vi.fn() await runtime.sessions.add({ id: 's1', session: { prompt } }) - expect(runtime.sessions.provideInfo('ghost')).toBeUndefined() expect(runtime.sessions.scope('ghost')).toBeUndefined() expect(runtime.sessions.binding('ghost')).toBeUndefined() @@ -140,69 +140,17 @@ describe('sessions', () => { const binding = runtime.sessions.binding('s1')! expect(binding.sessionId).toBe('s1') expect(binding.ctx).toBe(scope) - ;(binding.session as { prompt: () => void }).prompt() + await binding.session.prompt([], 'queue') expect(prompt).toHaveBeenCalledOnce() expect(runtime.sessions.behavior('s1')).toBe(binding.session) - // The binding's session doubles as the conversation observable face. - expect((binding.session as { getSnapshot(): { sessionId: string } }).getSnapshot().sessionId).toBe('s1') + expect(binding.session.getSnapshot().sessionId).toBe('s1') // A scoped service resolves through the scope ctx (scope-addressed pattern). - runtime.provide('probe', { hello: 'world' }) + runtime.ctx.provide('probe', { hello: 'world' }) expect(scope.get('probe')).toEqual({ hello: 'world' }) await runtime.dispose() }) - it('materializes provide bundles: built-in session hook, custom providers, no-session projection', async () => { - const runtime = await runtimeWithFrame() - await runtime.sessions.add({ id: 's1' }) - - const info = runtime.sessions.provideInfo('s1')! - expect(info.sessionId).toBe('s1') - expect(info.hooks['session']).toBeDefined() // the built-in useSession source - expect(runtime.sessions.provideInfo('s1')).toBe(info) // identity-stable - - // A feature provider (the ui-conversation input pattern): declared names - // materialize per session and land in the no-session roster as undefined. - const off = runtime.sessions.provide({ - hooks: ['probe'], - props: ['probeActions'], - resolve: binding => ({ - hooks: { probe: { getSnapshot: () => binding.sessionId, subscribe: () => () => {} } }, - props: { probeActions: { poke: () => {} } }, - }), - }) - const enriched = runtime.sessions.provideInfo('s1')! - expect(enriched.hooks['probe']?.getSnapshot()).toBe('s1') - expect(enriched.props['probeActions']).toBeDefined() - const maybe = runtime.sessions.maybeProvideInfo(undefined) - expect(maybe.sessionId).toBeUndefined() - expect(Object.keys(maybe.hooks)).toEqual(['session', 'probe']) - expect(runtime.sessions.maybeProvideInfo('s1')).toBe(runtime.sessions.provideInfo('s1')) - expect(runtime.sessions.maybeProvideInfo('ghost').sessionId).toBeUndefined() - - // Misdeclared providers fail loud AT REGISTRATION (the production - // channel rebuilds live bundles eagerly and rolls the roster back): - // missing hook, missing prop, duplicate hook, duplicate prop. - expect(() => runtime.sessions.provide({ hooks: ['void'], resolve: () => ({}) })) - .toThrow(/missing hook "void"/) - expect(() => runtime.sessions.provide({ props: ['void'], resolve: () => ({}) })) - .toThrow(/missing prop "void"/) - expect(() => runtime.sessions.provide({ - hooks: ['session'], - resolve: () => ({ hooks: { session: { getSnapshot: () => 0, subscribe: () => () => {} } } }), - })).toThrow(/duplicate hook "session"/) - const propA = runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 1 } }) }) - expect(() => runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 2 } }) })) - .toThrow(/duplicate prop "twice"/) - propA() - // The rejected registrations rolled back: the roster still materializes. - expect(runtime.sessions.provideInfo('s1')).toBeDefined() - off() - off() // disposer is idempotent - expect(Object.keys(runtime.sessions.maybeProvideInfo(undefined).hooks)).toEqual(['session']) - await runtime.dispose() - }) - it('records service-face calls and retains catalog addresses only for addressed selection', async () => { const runtime = await runtimeWithFrame() await runtime.sessions.add({ id: 's1' }) @@ -308,9 +256,11 @@ describe('stores', () => { it('storeOf guards: before renderRoot, and for storeless entries', async () => { const runtime = await runtimeWithFrame() runtime.slots.register({ name: 'trt.panel' }, () => null) + runtime.slots.register({ name: 'trt.chat', store: createSuiteStore() }, () => null) expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/) runtime.renderRoot() expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/) + expect(() => runtime.storeOf('trt.chat', 'missing')).toThrow(/no live Session binding/) await runtime.dispose() }) @@ -328,7 +278,7 @@ describe('stores', () => { await runtime.sessions.remove('s1') expect(localStorage.getItem('trt.store.s1')).toBeNull() expect(runtime.sessions.list.getSnapshot().ids).toEqual([]) - expect(runtime.sessions.provideInfo('s1')).toBeUndefined() + expect(runtime.sessions.binding('s1')).toBeUndefined() await runtime.sessions.add({ id: 's1' }) const reborn = runtime.storeOf('trt.chat', 's1') @@ -352,7 +302,7 @@ describe('stores', () => { }) describe('workspaces', () => { - it('feeds useWorkspaces and records/stubs intent actions', async () => { + it('feeds the renderer root source from the Workspace Controller snapshot', async () => { const runtime = await runtimeWithFrame() runtime.slots.register( { name: 'trt.panel' }, @@ -363,43 +313,6 @@ describe('workspaces', () => { await runtime.workspaces.update((draft) => { draft.phase = 'pending' }) expect(view.container.textContent).toContain('ws:pending') - - runtime.workspaces.startSession('w1' as WorkspaceId) - await expect(runtime.workspaces.connectWorkspace('w2' as WorkspaceId)).resolves.toBe('session-of-w2') - expect(runtime.workspaces.calls).toEqual([ - { method: 'startSession', args: ['w1'] }, - { method: 'connectWorkspace', args: ['w2'] }, - ]) - const stub = vi.fn(() => Promise.resolve('other' as never)) - runtime.workspaces.stub('connectWorkspace', stub) - await expect(runtime.workspaces.connectWorkspace('w3' as WorkspaceId)).resolves.toBe('other') - expect(stub).toHaveBeenCalledOnce() - await runtime.dispose() - }) - - it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => { - const runtime = await runtimeWithFrame() - // Defaults: an empty home level and parent/name joining. - await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) - await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) - await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') - // The recorded signal seat mirrors the production face (undefined here; - // cancellation tests pass and observe a real one). - expect(runtime.workspaces.calls).toEqual([ - { method: 'listDirectory', args: [undefined, undefined] }, - { method: 'listDirectory', args: ['/home/test', undefined] }, - { method: 'createDirectory', args: ['/home/test', 'fresh'] }, - ]) - // Stubs replace the defaults like every sibling method. - const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } - const listStub = vi.fn(() => Promise.resolve(listing as never)) - runtime.workspaces.stub('listDirectory', listStub) - runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never))) - const scan = new AbortController() - await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing) - // The stub receives the signal too, like the production face gives the wire. - expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal) - await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made') await runtime.dispose() }) }) @@ -407,7 +320,7 @@ describe('workspaces', () => { describe('feature mount and disposal', () => { it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => { const runtime = await runtimeWithFrame() - runtime.provide('layout', { openDetails: vi.fn() }) + runtime.ctx.provide('layout', { openDetails: vi.fn() }) const feature = await runtime.mount({ inject: ['slots', 'layout'], apply: (ctx: typeof runtime.ctx) => { @@ -530,9 +443,15 @@ describe('fixture session face', () => { await runtime.dispose() }) - it('projections faces are identity-stable per key, read absent, and notify on set', async () => { - const runtime = await SlotTestRuntime.create() + it('projects controller values through the real ui-session and renderer path', async () => { + const runtime = await runtimeWithFrame() + runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => ( + todos:{props.useProjection('todos', value => value?.length ?? 0)} + )) + const view = runtime.renderRoot() await runtime.sessions.add({ id: 's1' }) + expect(view.container.textContent).toContain('todos:0') + const session = runtime.sessions.behavior('s1') const face = session.projections.faceOf('todos') expect(session.projections.faceOf('todos')).toBe(face) @@ -540,27 +459,16 @@ describe('fixture session face', () => { const seen: unknown[] = [] const off = face.subscribe(() => { seen.push(face.getSnapshot()) }) session.projections.set('todos', [1, 2]) + await runtime.flush() expect(seen).toEqual([[1, 2]]) + expect(view.container.textContent).toContain('todos:2') off() session.projections.set('todos', [3]) + await runtime.flush() expect(seen).toEqual([[1, 2]]) // unsubscribed + expect(view.container.textContent).toContain('todos:1') // A never-subscribed key sets without listeners (the empty-notify arm). session.projections.set('untouched', 1) - // The provide bundle hands the same store to the render side. - const info = runtime.sessions.provideInfo('s1')! - expect(info.projections?.faceOf('todos').getSnapshot()).toEqual([3]) - // A roster change rebuilds the ALREADY-materialized bundle eagerly - // (production channel semantics: mounted entries must see the provider) - // and skips never-materialized records (they pick the roster up lazily). - await runtime.sessions.add({ id: 's-lazy' }, { current: false }) - const offProbe = runtime.sessions.provide({ - hooks: ['probe2'], - resolve: () => ({ hooks: { probe2: { getSnapshot: () => 1, subscribe: () => () => {} } } }), - }) - const rebuilt = runtime.sessions.provideInfo('s1')! - expect(rebuilt).not.toBe(info) - expect(rebuilt.hooks['probe2']).toBeDefined() - offProbe() await runtime.dispose() }) }) @@ -573,11 +481,9 @@ describe('workspaces action face', () => { expect(created.title).toBe('/tmp/alpha') const registered = await ws.create({ path: '/tmp/beta' }) expect(registered.path).toBe('/tmp/beta') - await expect(ws.pickDirectory()).resolves.toBeNull() const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed') expect(renamed.title).toBe('Renamed') await ws.delete('w1' as WorkspaceId) - await ws.openPath('/proj/file.ts') await ws.insertBefore('w1' as WorkspaceId, 'w2' as WorkspaceId) const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId) expect(moved.sessionIds).toEqual(['s1']) @@ -586,22 +492,18 @@ describe('workspaces action face', () => { await ws.archiveSession('s1' as SessionId) expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( - ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertBefore', 'insertSessionBefore', 'archiveSession']) + ['create', 'create', 'rename', 'delete', 'insertBefore', 'insertSessionBefore', 'archiveSession']) ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never)) - ws.stub('pickDirectory', () => Promise.resolve('/picked')) ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never)) ws.stub('delete', () => Promise.resolve()) - ws.stub('openPath', () => Promise.resolve()) const insertBefore = vi.fn(() => Promise.resolve()) ws.stub('insertBefore', insertBefore) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) expect((await ws.create({ path: '/y' })).title).toBe('X') - await expect(ws.pickDirectory()).resolves.toBe('/picked') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) - await ws.openPath('/other') await ws.insertBefore('w2' as WorkspaceId) expect(insertBefore).toHaveBeenCalledWith('w2', undefined) expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index 63e3a17eba..91a07be843 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -11,6 +11,30 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../api/workspace-controller/tsconfig.client.json" + }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../core/session" + }, + { + "path": "../../client/connection/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../client/ui-chat" + }, + { + "path": "../../client/ui-conversation" + }, { "path": "../../client/ui-slots" }, @@ -18,13 +42,13 @@ "path": "../../client/ui-renderer" }, { - "path": "../../client/runtime" + "path": "../../client/ui-session" + }, + { + "path": "../../client/ui-settings" }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../host/apiproxy" } ] } diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 54c32c66fc..c1cc4c19de 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -54,7 +54,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index dc2283a98c..592f158872 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -17,18 +17,11 @@ import type { Session } from '@deepseek-ai/dsh-session' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import UserQuestionService from '@deepseek-ai/dsh-user-questions' -import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' -let nextRpc = 1 -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`todo-proj-${String(nextRpc++)}`), payload } -} - interface Bench { ctx: Context session: Session @@ -46,14 +39,16 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const history = new SessionHistoryController(ctx) return { ctx, session, async tailProjections() { - const response = await api.sessions.history(request({ sessionId: session.id })) - if (!response.result.ok) throw new Error('history failed') - return response.result.value.projections + return (await history.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + }, new AbortController().signal)) + .projections }, } } diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 0eee1ff61a..a27f577da2 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -976,7 +976,7 @@ class FaceAnalyzer { binding: GatewayBinding, method: ts.MethodDeclaration, invocation: - | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } | { readonly kind: 'context'; readonly context: string; readonly exportName?: string }, ): InvocationModel { if (visibilityOf(method) !== 'public' || hasModifier(method, ts.SyntaxKind.StaticKeyword)) { @@ -1106,13 +1106,15 @@ class FaceAnalyzer { } } - const resultType = this.remoteResultType(method) + const mode = invocation.kind === 'direct' ? invocation.mode : undefined + const resultType = this.remoteResultType(method, mode) return { id: `${registration.name}#${binding.namespace}/${exportedMethod}`, service: binding.service, namespace: binding.namespace, method: exportedMethod, ...(exportedMethod === methodName ? {} : { implementation: methodName }), + ...(mode === undefined ? {} : { mode }), invocation: receiver, ...(scope === undefined ? {} : { scope }), parameters, @@ -1215,11 +1217,11 @@ class FaceAnalyzer { private remoteMarker( member: ts.ClassElement, ): - | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } | undefined { let found: - | { readonly kind: 'direct'; readonly exportName?: string } + | { readonly kind: 'direct'; readonly exportName?: string; readonly mode?: 'stream' } | { readonly kind: 'context'; readonly context: string; readonly exportName?: string } | undefined for (const decorator of ts.canHaveDecorators(member) ? ts.getDecorators(member) ?? [] : []) { @@ -1229,12 +1231,28 @@ class FaceAnalyzer { marker = { kind: 'direct' } } else if (ts.isCallExpression(expression) && this.isTypeMetaSymbol(expression.expression, 'Remote')) { - if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one exported method name') - const exportName = stringLiteralValue(expression.arguments[0]) - if (exportName === undefined || !isRemoteSegment(exportName)) { - this.fail(expression.arguments[0] ?? expression, 'Remote() name must be a string literal containing only RPC endpoint segment characters') + if (expression.arguments.length !== 1) this.fail(expression, 'Remote() requires one name or options object') + const argument = expression.arguments[0] + if (argument === undefined) this.fail(expression, 'Remote() requires one name or options object') + const exportName = stringLiteralValue(argument) + if (exportName !== undefined) { + if (!isRemoteSegment(exportName)) { + this.fail(argument, 'Remote() name must contain only RPC endpoint segment characters') + } + marker = { kind: 'direct', exportName } + } else { + if (!ts.isObjectLiteralExpression(argument) || argument.properties.length !== 1) { + this.fail(argument, 'Remote() options must contain exactly mode: "stream"') + } + const [property] = argument.properties + if (property === undefined) this.fail(argument, 'Remote() options must contain exactly mode: "stream"') + if (!ts.isPropertyAssignment(property) + || memberName(property.name) !== 'mode' + || stringLiteralValue(property.initializer) !== 'stream') { + this.fail(property, 'Remote() options must contain exactly mode: "stream"') + } + marker = { kind: 'direct', mode: 'stream' } } - marker = { kind: 'direct', exportName } } else if (ts.isCallExpression(expression) && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { if (expression.arguments.length < 1 || expression.arguments.length > 2) { @@ -1259,16 +1277,27 @@ class FaceAnalyzer { return found } - private remoteResultType(method: ts.MethodDeclaration): ts.TypeNode { + private remoteResultType(method: ts.MethodDeclaration, mode?: 'stream'): ts.TypeNode { const authored = this.requiredType(method, method.type, 'return') - if (!ts.isTypeReferenceNode(authored)) return authored - const symbol = this.checker.getSymbolAtLocation(authored.typeName) - const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol) - const resultType = authored.typeArguments?.[0] - if (resolved?.name !== 'Promise' || resultType === undefined || authored.typeArguments?.length !== 1) return authored - const declaration = preferredDeclaration(resolved) - if (declaration === undefined || !isStandardLibraryFile(declaration.getSourceFile().fileName)) return authored - return resultType + if (ts.isTypeReferenceNode(authored)) { + const symbol = this.checker.getSymbolAtLocation(authored.typeName) + const resolved = symbol === undefined ? undefined : this.resolveSymbol(symbol) + const resultType = authored.typeArguments?.[0] + const wrappers = mode === 'stream' ? ['Iterable', 'AsyncIterable'] : ['Promise'] + const declaration = resolved === undefined ? undefined : preferredDeclaration(resolved) + if (resolved !== undefined + && wrappers.includes(resolved.name) + && resultType !== undefined + && authored.typeArguments?.length === 1 + && declaration !== undefined + && isStandardLibraryFile(declaration.getSourceFile().fileName)) { + return resultType + } + } + if (mode === 'stream') { + this.fail(method, 'stream Remote methods must return Iterable or AsyncIterable') + } + return authored } private isGlobalAbortSignal(type: ts.TypeNode): boolean { diff --git a/packages/typert/generator/src/emitter.ts b/packages/typert/generator/src/emitter.ts index a8216426d2..4b036372d6 100644 --- a/packages/typert/generator/src/emitter.ts +++ b/packages/typert/generator/src/emitter.ts @@ -282,6 +282,7 @@ export class FaceModelEmitter { if (invocation.implementation !== undefined) { lines.push(` implementation: ${quote(invocation.implementation)},`) } + if (invocation.mode !== undefined) lines.push(` mode: ${quote(invocation.mode)},`) if (invocation.invocation.kind === 'direct') { lines.push(' invocation: { kind: \'direct\' },') } else { @@ -467,9 +468,11 @@ export class FaceModelEmitter { `${safeIdentifier(parameter.wire)}${parameter.optional === true ? '?' : ''}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`) if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal') const result = this.renderer.renderType(invocation.result.type, referenceNames) - // The Client Remote face delivers the carrier's outcome, so every generated - // consumer signature resolves to a result the caller reads instead of a - // value it must guard with its own try/catch. + if (invocation.mode === 'stream') { + return `(${parameters.join(', ')}) => AsyncIterable<${result}>` + } + // The unary Client Remote face delivers the carrier's outcome, so every + // generated consumer signature resolves to a result the caller reads. return `(${parameters.join(', ')}) => Promise>` } } diff --git a/packages/typert/generator/src/model.ts b/packages/typert/generator/src/model.ts index 1b8402b6d2..477c4368c4 100644 --- a/packages/typert/generator/src/model.ts +++ b/packages/typert/generator/src/model.ts @@ -131,6 +131,7 @@ export interface InvocationModel { readonly namespace: string readonly method: string readonly implementation?: string + readonly mode?: 'stream' readonly invocation: | { readonly kind: 'direct' } | { diff --git a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts index e72d838b47..514722123b 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/packages/remote/src/index.ts @@ -23,6 +23,12 @@ export class GoalService extends TypertRemoteService { rename(request: RenameGoalRequest): RenameGoalResult { return { renamed: request.title.length > 0 } } + + @Remote({ mode: 'stream' }) + async *watch(agent: Agent, signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + yield { ref: agent.id } + } } export type { diff --git a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts index 3ad5bedddb..37c741915c 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts @@ -60,7 +60,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { context: ClassMethodDecoratorContext Result>, ): void - export function Remote(exportName: string): + export function Remote(option: string | { readonly mode: 'stream' }): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index 21804c52fa..12ff2f4d07 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -20,6 +20,7 @@ interface RuntimeSchema { interface RuntimeDescriptor { readonly id: string + readonly mode?: 'stream' readonly cancellation?: { readonly parameter: 'signal' } readonly parameters: readonly { readonly wire: string @@ -66,7 +67,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { const model = remotePackage(fixtureRoot) expect(model.services).toEqual([]) - expect(model.invocations).toHaveLength(2) + expect(model.invocations).toHaveLength(3) expect(model.invocations[0]).toMatchObject({ id: '@fixture/remote#goals/create', service: 'goals', @@ -111,6 +112,22 @@ describe('Remote model generation', { timeout: 60_000 }, () => { }], result: { typeSymbol: '@fixture/remote/types#RenameGoalResult' }, }) + expect(model.invocations[2]).toMatchObject({ + id: '@fixture/remote#goals/watch', + service: 'goals', + namespace: 'goals', + method: 'watch', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + }], + cancellation: { parameter: 'signal' }, + result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' }, + }) expect(artifact?.js).toContain('invocations: [') expect(artifact?.remote?.dts).toContain( @@ -124,6 +141,9 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(artifact?.remote?.dts).toContain( "'agent:goals/rename': (request: RenameGoalRequest) => Promise>", ) + expect(artifact?.remote?.dts).toContain( + "'goals/watch': (agentId: AgentId, signal?: AbortSignal) => AsyncIterable", + ) const remoteJs = artifact?.remote?.js if (remoteJs === undefined) throw new Error('Remote fixture emitted no Host-for-Client JavaScript') @@ -136,6 +156,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => { expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false) expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true) expect(create?.result.schema.safeParse({ ref: 1 }).success).toBe(false) + expect(generated.TYPERT_REMOTE.descriptors[2]?.mode).toBe('stream') const declarationMap = JSON.parse(artifact?.remote?.dtsMap ?? '') as RemoteDeclarationMap expect(declarationMap).toMatchObject({ @@ -241,17 +262,15 @@ export type GenericResult = { ' RenameGoalResult,\n GenericRequest,\n GenericResult,\n', ) .replace( - ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', - ` rename(request: RenameGoalRequest): RenameGoalResult { - return { renamed: request.title.length > 0 } - } - - @Remote + " @Remote({ mode: 'stream' })\n async *watch", + ` @Remote dispatch(request: GenericRequest): GenericResult { if (request.kind === 'ship') return { kind: 'ship', value: { accepted: request.payload.count > 0 } } return { kind: 'cancel', value: { cancelled: request.payload.reason.length > 0 } } } -}`, + + @Remote({ mode: 'stream' }) + async *watch`, )) const [artifact] = new WorkspaceTypertGenerator(root).generate() @@ -292,16 +311,14 @@ export interface BoxPayload { ' RenameGoalResult,\n Box,\n BoxPayload,\n', ) .replace( - ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', - ` rename(request: RenameGoalRequest): RenameGoalResult { - return { renamed: request.title.length > 0 } - } - - @Remote + " @Remote({ mode: 'stream' })\n async *watch", + ` @Remote box(request: Box): Box { return request } -}`, + + @Remote({ mode: 'stream' }) + async *watch`, )) const [artifact] = new WorkspaceTypertGenerator(root).generate() @@ -313,16 +330,14 @@ export interface BoxPayload { it('quotes aliased methods in generated namespace interfaces', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace( - ' rename(request: RenameGoalRequest): RenameGoalResult {\n return { renamed: request.title.length > 0 }\n }\n}', - ` rename(request: RenameGoalRequest): RenameGoalResult { - return { renamed: request.title.length > 0 } - } - - @Remote('create-goal') + " @Remote({ mode: 'stream' })\n async *watch", + ` @Remote('create-goal') createAlias(request: CreateGoalRequest): CreateGoalResult { return { ref: request.title } } -}`, + + @Remote({ mode: 'stream' }) + async *watch`, )) const [artifact] = new WorkspaceTypertGenerator(root).generate() @@ -343,8 +358,9 @@ export interface BoxPayload { it('rejects a Remote export after its last Remote method is removed', () => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source - .replace(' @Remote\n', '') - .replace(" @RemoteScope('agent')\n", '')) + .replaceAll(' @Remote\n', '') + .replace(" @RemoteScope('agent')\n", '') + .replace(" @Remote({ mode: 'stream' })\n", '')) editFile(root, 'packages/remote/src/types.ts', source => `${source} /** @typert schema */ diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index af8408917c..90f8a626e2 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/protocol/README.md -README.md: 84f9f1b31cca35cffce5dc5fc84db1017adaad08 -README.zh.md: 97e4734a7292f0a305ff8ed00ff1d72d1a53b5e9 +README.md: 3d8df3808a378cae115edb938160f73766f68747 +README.zh.md: 15247f37b0c08c1e0e9de38d159e5e7aea945109 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index 84f9f1b31c..3d8df3808a 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -20,9 +20,9 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypertLookupMap` and `TypertContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypertRemoteMap`, `TypertRemoteScopeMap`, and `TypertRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client Remote. -The Host assembly extends `TypertRemoteEventSelection` with the Host events it forwards to consumers, which narrows the `ctx.remote.$on` key face; `TypertForwardableEvent` states the shapes a one-way delivery can carry at all, excluding Scope-bound and answered events. `TypertClientRemote` carries both roles of that surface: consumers subscribe through `$on`, and the Client half owning the host frame sink hands frames over through `$dispatch`. +The Host assembly extends `TypertRemoteEventSelection` with the Cordis events it forwards to consumers, which narrows the `ctx.remote.$on` key face. `TypertForwardableEvent` accepts unscoped `void` notifications and scoped async waterfalls whose final `next()` callback returns the event's result type. `TypertClientEventListener` derives the Client listener from that same `Events` member: Host subject types declared under matching `TypertLookupMap` and `TypertContextMap` keys become Client `Context`, while `AbortSignal`, optional and readonly object fields, arrays, callbacks, and result types are preserved. `TypertClientRemote` exposes only `$mount()` and `$on()`; event transport is private to Gateway. -Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypertLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. +Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. Host and Client Context adapters both map `Context -> wire identity` and `wire identity -> Context`; the Host adapter additionally supplies the stable wire declaration, and Host composition may override its synchronous or asynchronous resolver. Policy rejections may use `TypertLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index 97e4734a72..15247f37b0 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -20,9 +20,9 @@ 业务包扩展 `TypertLookupMap` 和 `TypertContextMap`,以关联宿主对象或作用域 Context 与其协议身份。生成的产物扩展 `TypertRemoteMap`、`TypertRemoteScopeMap` 和 `TypertRemoteNamespaceMap`,使客户端导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、网关和客户端 Remote 使用的共享运行时形式。 -Host 装配以转发给消费端的 Host 事件扩展 `TypertRemoteEventSelection`,从而收窄 `ctx.remote.$on` 的键面;`TypertForwardableEvent` 陈述单向投递根本能承载哪些形状,把 Scope 化事件与有返回值的事件排除在外。`TypertClientRemote` 承载该面的两种角色:消费方经 `$on` 订阅,持有 Host 帧 sink 的 Client 半经 `$dispatch` 交出帧。 +Host 装配以转发给消费端的 Cordis 事件扩展 `TypertRemoteEventSelection`,从而收窄 `ctx.remote.$on` 的键面。`TypertForwardableEvent` 接受无作用域且返回 `void` 的通知,以及最后一个 `next()` 回调返回事件结果类型的异步作用域 waterfall。`TypertClientEventListener` 从同一条 `Events` 成员派生 Client listener:在 `TypertLookupMap` 与 `TypertContextMap` 中使用同名 key 声明的 Host subject 类型会变为 Client `Context`,同时保留 `AbortSignal`、可选与只读对象字段、数组、回调和结果类型。`TypertClientRemote` 只公开 `$mount()` 与 `$on()`;事件传输由 Gateway 私有持有。 -查找包与 Context 包同时负责该约定的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。查找提供方或宿主 Context 提供方提供稳定声明与默认解析器,宿主组合可以另行配置同步或异步解析器;策略拒绝可用 `TypertLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 +查找包与 Context 包同时负责该约定的两侧:声明合并提供静态关联,运行时提供方向 `ctx.typert` 注册身份解析。Host 与 Client Context adapter 都提供 `Context -> wire identity` 和 `wire identity -> Context`;Host adapter 还提供稳定的 wire 声明,Host 组合可以覆盖其同步或异步 resolver。策略拒绝可用 `TypertLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index 8481c40343..ad8d973f2f 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -5,7 +5,7 @@ */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { TypertContextMap } from './types.ts' +import type { RemoteFailure, TypertContextMap } from './types.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ @@ -37,22 +37,42 @@ export class TypertLookupFailure extends Error { } } +/** A business Remote rejection preserved by unary and stream carriers. */ +export class TypertRemoteFailure extends Error { + /** Stable caller-facing failure payload. */ + readonly failure: RemoteFailure + + /** + * Wrap one business rejection for transport without changing its code or details. + * @param failure - business failure returned unchanged to the caller. + */ + constructor(failure: RemoteFailure) { + super(failure.message) + this.name = 'TypertRemoteFailure' + this.failure = failure + } +} + export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, RemoteFailure, RemoteResult, + TypertClientEventListener, TypertClientRemote, - TypertClientContextBinder, + TypertClientContextAdapter, TypertCodec, TypertContext, + TypertContextAdapter, TypertContextMap, TypertContextRegistry, TypertContextWire, TypertDisposer, TypertForwardableEvent, - TypertHostContextProvider, + TypertForwardableEventEntry, + TypertHostContextAdapter, + TypertHostContextIdentity, TypertHostContextResolver, TypertLocalRegistry, TypertLookup, @@ -103,9 +123,17 @@ export interface RemoteMethodMarker { readonly method: string /** Endpoint method when it differs from the implementation member. */ readonly exportName?: string + /** Stream methods yield many independently validated result items. */ + readonly mode?: 'stream' readonly invocation: RemoteInvocationMarker } +/** Options for a non-unary Remote method. */ +export interface RemoteMethodOptions { + /** Deliver each Iterable item over the shared logical-stream carrier. */ + readonly mode: 'stream' +} + type RemoteMethodDecorator = ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, @@ -120,6 +148,7 @@ interface RemoteInitializerContext { interface StoredRemoteMethodMarker { readonly exportName?: string + readonly mode?: 'stream' readonly invocation: RemoteInvocationMarker } @@ -170,31 +199,47 @@ export function Remote( context: ClassMethodDecoratorContext Result>, ): void /** - * Mark one public instance method under a distinct exported method name. - * @param exportName - Remote endpoint method, without a namespace or slash. + * Mark one public instance method under an exported name or as a logical stream. + * @param option - endpoint method name or stream delivery mode. * @returns a standard method decorator. */ -export function Remote(exportName: string): RemoteMethodDecorator +export function Remote(option: string | RemoteMethodOptions): RemoteMethodDecorator export function Remote( - methodOrExportName: string | ((this: This, ...args: Args) => Result), + methodExportOrOptions: string | RemoteMethodOptions | ((this: This, ...args: Args) => Result), context?: ClassMethodDecoratorContext Result>, ): void | RemoteMethodDecorator { - if (typeof methodOrExportName === 'string') { - validateName('Remote export name', methodOrExportName) - return function ( - _method: (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult, - decoratorContext: ClassMethodDecoratorContext< - DecoratorThis, - (this: DecoratorThis, ...args: DecoratorArgs) => DecoratorResult - >, - ): void { - addMarkerInitializer(decoratorContext, { kind: 'direct' }, methodOrExportName) + if (typeof methodExportOrOptions === 'string') { + validateName('Remote export name', methodExportOrOptions) + return remoteDecorator({ kind: 'direct' }, undefined, methodExportOrOptions) + } + if (typeof methodExportOrOptions === 'object') { + if (remoteOptionMode(methodExportOrOptions) !== 'stream' + || Reflect.ownKeys(methodExportOrOptions).length !== 1) { + throw new TypeError('typert-protocol: Remote options must contain exactly mode: "stream"') } + return remoteDecorator({ kind: 'direct' }, 'stream') } if (context === undefined) throw new TypeError('typert-protocol: Remote decorator context is missing') addMarkerInitializer(context, { kind: 'direct' }) } +function remoteOptionMode(options: object): unknown { + return Reflect.get(options, 'mode') as unknown +} + +function remoteDecorator( + invocation: RemoteInvocationMarker, + mode?: 'stream', + exportName?: string, +): RemoteMethodDecorator { + return function ( + _method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>, + ): void { + addMarkerInitializer(context, invocation, mode, exportName) + } +} + /** * Create a decorator for a method resolved from one Remote Scope. * @param key - scope key declared through the Context map. @@ -207,12 +252,7 @@ export function RemoteScope( ): RemoteMethodDecorator { validateName('Scope key', key) if (exportName !== undefined) validateName('Remote export name', exportName) - return function ( - _method: (this: This, ...args: Args) => Result, - context: ClassMethodDecoratorContext Result>, - ): void { - addMarkerInitializer(context, { kind: 'context', context: key }, exportName) - } + return remoteDecorator({ kind: 'context', context: key }, undefined, exportName) } /** @@ -230,6 +270,7 @@ export function remoteMethods(service: object): readonly RemoteMethodMarker[] { function addMarkerInitializer( context: RemoteInitializerContext, invocation: RemoteInvocationMarker, + mode?: 'stream', exportName?: string, ): void { if (context.private || context.static || typeof context.name !== 'string') { @@ -241,7 +282,7 @@ function addMarkerInitializer( if (prototype === null) { throw new TypeError(`typert-protocol: cannot mark Remote method "${method}" on an object without a prototype`) } - mark(prototype, method, invocation, exportName) + mark(prototype, method, invocation, mode, exportName) }) } @@ -249,6 +290,7 @@ function mark( prototype: object, method: string, invocation: RemoteInvocationMarker, + mode?: 'stream', exportName?: string, ): void { let table = markers.get(prototype) @@ -258,11 +300,14 @@ function mark( } const marker: StoredRemoteMethodMarker = { ...(exportName === undefined || exportName === method ? {} : { exportName }), + ...(mode === undefined ? {} : { mode }), invocation: Object.freeze(invocation), } const current = table.get(method) if (current !== undefined) { - if (current.exportName === marker.exportName && sameInvocation(current.invocation, invocation)) return + if (current.exportName === marker.exportName + && current.mode === marker.mode + && sameInvocation(current.invocation, invocation)) return throw new Error(`typert-protocol: Remote method "${method}" has conflicting invocation markers`) } table.set(method, Object.freeze(marker)) diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 2f8c4a6276..8123b2a36a 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -54,7 +54,7 @@ export interface RemoteFailure { * What every generated Remote method resolves to. The Remote face itself folds * carrier failures into the error branch, so no consumer wraps a call to * recover one; only assembly faults (arity, an unmounted method, a missing - * Context binder) still reject. + * Context adapter) still reject. * @template T - the Host method's business result. */ export type RemoteResult = @@ -64,23 +64,92 @@ export type RemoteResult = /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypertRemoteScopeMap {} +type TypertEventParameters = + Events[Event] extends (...args: infer Args) => unknown ? Args : never + +type TypertEventResult = + Events[Event] extends (...args: never[]) => infer Result ? Result : never + +type TypertProjectedContextKey = Extract + +type TypertProjectedContextSubject = { + [Key in TypertProjectedContextKey]: TypertLookupHost +}[TypertProjectedContextKey] + +type TypertAgentScopedRequest = Request extends object + ? 'agent' extends keyof Request + ? Exclude extends TypertProjectedContextSubject ? Request : never + : never + : never + +type TypertWaterfallEvent = + unknown extends ThisParameterType + ? never + : TypertEventParameters extends [infer Request, infer Next] + ? Next extends () => TypertEventResult + ? TypertEventResult extends Promise + ? TypertAgentScopedRequest extends never ? never : Event + : never + : never + : never + +type TypertForwardingMode = + unknown extends ThisParameterType + ? TypertEventResult extends void ? 'emit' : never + : TypertWaterfallEvent extends never ? never : 'waterfall' + /** - * Cordis event names whose shape a one-way Remote delivery can carry: unbound - * from any Scope and returning `void`. Which ones are actually forwarded is the - * Host assembly's selection; this predicate only excludes shapes the carrier - * cannot represent. + * Cordis event names the Remote Event carrier can preserve without a second + * signature declaration: unscoped `void` notifications and scoped async + * waterfalls whose final parameter is their same-result `next()` callback. */ export type TypertForwardableEvent = { - [Event in keyof Events]: unknown extends ThisParameterType - ? ReturnType extends void ? Event : never + [Event in keyof Events]: TypertForwardingMode extends never ? never : Event +}[keyof Events] + +/** Event and dispatch mode accepted by the Remote Event source. */ +export type TypertForwardableEventEntry = { + [Event in keyof Events]: TypertForwardingMode extends infer Mode + ? Mode extends 'emit' | 'waterfall' + ? { readonly event: Event; readonly mode: Mode } + : never : never }[keyof Events] /** Merge-extensible forwarding selection declared once by the Host assembly. */ export interface TypertRemoteEventSelection {} -/** Legal `$on` keys: selected events that exist in the current compilation face. */ -export type TypertRemoteEvent = Extract +/** Legal `$on` keys selected from the carrier-compatible Cordis event declarations. */ +export type TypertRemoteEvent = Extract + +type TypertClientAgent = + Exclude extends TypertProjectedContextSubject + ? Context | Extract + : Value + +type TypertClientEventRequest = Request extends object + ? { [Key in keyof Request]: Key extends 'agent' ? TypertClientAgent : Request[Key] } + : never + +type TypertScopedClientEventListener = + Events[Event] extends (request: infer Request, next: infer Next) => infer Result + ? ( + this: Context, + request: TypertClientEventRequest, + next: Next, + ) => Result + : never + +/** + * Listener derived from one selected Cordis event declaration. Scoped Host + * subjects become the resolved Client `Context`; one-way notifications retain + * their declaration unchanged. + * @template Event - selected Remote Event name. + */ +export type TypertClientEventListener = + unknown extends ThisParameterType + ? Events[Event] + : TypertScopedClientEventListener /** * Resolve one direct Remote namespace from the generated flat endpoint map. @@ -181,6 +250,8 @@ export interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string + /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -192,7 +263,7 @@ export interface InvocationDescriptor { } /** Optional consuming-Context projection for one direct lookup parameter. */ readonly scope?: { - /** Context kind whose Client binder supplies the identity. */ + /** Context kind whose Client adapter supplies the identity. */ readonly context: string /** Lookup parameter wire field replaced by the Context identity. */ readonly wire: string @@ -204,7 +275,7 @@ export interface InvocationDescriptor { /** Reserved final Host method parameter. */ readonly parameter: 'signal' } - /** Codec for the resolved method result. */ + /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation @@ -227,26 +298,15 @@ export interface TypertClientRemote extends TypertRemoteNamespaceMap { */ $mount(contribution: TypertRemoteContribution): Promise /** - * Subscribe to one forwarded Host event; delivery is one-way, in registration - * order, and isolates a throwing listener from the rest. + * Subscribe to one forwarded Host event. Notifications run in registration + * order and isolate failures; scoped waterfalls return, delegate through + * `next()`, or reject the Host dispatch. * @template Event - forwarded event name selected by the Host assembly. * @param event - forwarded Host event name, unchanged on the wire. - * @param listener - receives the Host's argument list as declared by Cordis `Events`. + * @param listener - receives the Client projection of the Cordis `Events` declaration. * @returns disposer owned by the calling fiber. */ - $on(event: Event, listener: Events[Event]): () => void - /** - * Hand one decoded forwarded frame to the subscription table. The carrier - * owning the Host frame sink calls this; a consumer subscribes with - * {@link TypertClientRemote.$on} and never calls it. - * - * `event` is a plain string because this is the wire boundary: the name is - * whatever the Host assembly's allowlist selected, and one nobody subscribed - * to is dropped silently. - * @param event - forwarded Host event name, exactly as the Host emitted it. - * @param args - the Host argument list, already JSON-decoded. - */ - $dispatch(event: string, args: readonly unknown[]): void + $on(event: Event, listener: TypertClientEventListener): () => void } /** @@ -290,33 +350,58 @@ export interface TypertLookupDefinition { readonly wireTypeSymbol: string } -/** Host resolver for one scoped Remote kind. */ -export interface TypertHostContextProvider { - /** Wire field carrying the Context identity. */ - readonly wire: string - /** Canonical wire type symbol used by strict generation. */ - readonly wireTypeSymbol: string +/** Bidirectional projection between one environment's Context and its wire identity. */ +export interface TypertContextAdapter { /** - * Resolve a wire identity to its live scoped Context. + * Read the identity represented by a live Context. + * @param ctx - Context in this adapter's environment. + * @returns the wire identity, or `undefined` when the Context has another kind. + */ + identity(ctx: Context): Wire | undefined + /** + * Resolve a wire identity to a live Context in this adapter's environment. + * An asynchronous Client resolver may wait for its owner to create the Context. * @param id - validated wire identity. - * @returns the scoped Context, or `undefined` when unavailable. + * @returns the Context, or `undefined` when it is unavailable. */ resolve(id: Wire): Context | undefined | Promise } -/** Composition-owned resolver replacing one Host Context provider's default lookup policy. */ +/** Host Context adapter plus the wire declaration used by strict Remote methods. */ +export interface TypertHostContextAdapter extends TypertContextAdapter { + /** Wire field carrying the Context identity. */ + readonly wire: string + /** Canonical wire type symbol used by strict generation. */ + readonly wireTypeSymbol: string +} + +/** Composition-owned resolver replacing one Host Context adapter's default lookup policy. */ export type TypertHostContextResolver = ( id: Wire, ) => Context | undefined | Promise -/** Client resolver for the identity carried by the calling scoped Context. */ -export interface TypertClientContextBinder { +/** Client-side bidirectional Context adapter. */ +export interface TypertClientContextAdapter { /** - * Read the Remote identity represented by a calling Context. - * @param ctx - Context rebound by the Cordis service tracker. - * @returns the wire identity, or `undefined` when the Context has the wrong scope. + * Read the identity represented by a live Client Context. + * @param ctx - Client Context inspected by a scoped Remote caller. + * @returns the wire identity, or `undefined` for another Context kind. */ identity(ctx: Context): Wire | undefined + /** + * Resolve a wire identity from the Client's currently materialized Contexts. + * @param id - validated wire identity. + * @returns the Client Context, or `undefined` when unavailable. + */ + resolve(id: Wire): Context | undefined +} + +/** Host Context identity selected from the registered adapter set. */ +export interface TypertHostContextIdentity { + /** Merge-declared Context kind whose adapter recognized the Context. */ + readonly kind: string + /** Wire identity returned by that adapter. */ + readonly identity: unknown } /** Notification emitted after a Typert runtime registry changes. */ @@ -423,20 +508,20 @@ export interface TypertLookupRegistry { subscribe(listener: TypertRegistryListener): TypertDisposer } -/** Runtime registry for Host Context resolvers and Client Context binders. */ +/** Runtime registry for the Host and Client adapters of each Context kind. */ export interface TypertContextRegistry { /** - * Register a Host Context resolver. + * Register a Host Context adapter. * @param key - merge-declared Context key. - * @param provider - owning package's Host resolver. - * @returns disposer withdrawing the exact provider. + * @param adapter - owning package's bidirectional Host projection. + * @returns disposer withdrawing the exact adapter. */ registerHost>( key: K, - provider: TypertHostContextProvider>, + adapter: TypertHostContextAdapter>, ): TypertDisposer /** - * Override one Host Context key's identity policy for the calling fiber. + * Override one Host Context key's resolution policy for the calling fiber. * Configuration may precede provider registration and restores the provider's default resolver on disposal. * @param key - merge-declared Context key. * @param resolver - composition-owned resolver used by every Host Context lookup of this key. @@ -447,29 +532,36 @@ export interface TypertContextRegistry { resolver: TypertHostContextResolver>, ): TypertDisposer /** - * Register a Client Context identity binder. + * Register a Client Context adapter. * @param key - merge-declared Context key. - * @param binder - Client scope identity resolver. - * @returns disposer withdrawing the exact binder. + * @param adapter - owning package's bidirectional Client projection. + * @returns disposer withdrawing the exact adapter. */ registerClient>( key: K, - binder: TypertClientContextBinder>, + adapter: TypertClientContextAdapter>, ): TypertDisposer /** - * Look up a Host Context resolver. - * @param key - descriptor Context key. - * @returns the provider, or `undefined` when absent. + * Identify a live Host Context through the sole registered adapter set. + * @param ctx - Context projected by a Host-to-Client scoped event. + * @returns its kind and wire identity, or `undefined` when no adapter recognizes it. + * @throws when more than one Context kind recognizes the same Context. */ - getHost(key: string): TypertHostContextProvider | undefined + identifyHost(ctx: Context): TypertHostContextIdentity | undefined /** - * Look up a Client Context binder. + * Look up a Host Context adapter. * @param key - descriptor Context key. - * @returns the binder, or `undefined` when absent. + * @returns the adapter, or `undefined` when absent. */ - getClient(key: string): TypertClientContextBinder | undefined + getHost(key: string): TypertHostContextAdapter | undefined /** - * Observe later Context provider changes. + * Look up a Client Context adapter. + * @param key - descriptor Context key. + * @returns the adapter, or `undefined` when absent. + */ + getClient(key: string): TypertClientContextAdapter | undefined + /** + * Observe later Context adapter changes. * @param listener - synchronous contained observer. * @returns disposer for this subscription. */ diff --git a/packages/typert/protocol/tests/protocol.spec.ts b/packages/typert/protocol/tests/protocol.spec.ts index 332ce183c5..84e919edc5 100644 --- a/packages/typert/protocol/tests/protocol.spec.ts +++ b/packages/typert/protocol/tests/protocol.spec.ts @@ -8,11 +8,25 @@ import { Remote, RemoteScope, remoteMethods, + type TypertClientEventListener, type TypertContext, type TypertForwardableEvent, + type TypertForwardableEventEntry, + type TypertLookup, type TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' +interface MetaFixtureSubject { + readonly subjectId: string +} + +interface MetaFixtureRequest { + readonly agent: MetaFixtureSubject + readonly signal?: AbortSignal + readonly nested: readonly [{ readonly owner?: MetaFixtureSubject }] + readonly transform: (subject: MetaFixtureSubject) => Promise +} + declare module '@deepseek-ai/cordis' { interface Events { /** @@ -25,6 +39,17 @@ declare module '@deepseek-ai/cordis' { * @param value - marker payload. */ 'meta-fixture/scoped'(this: Context, value: string): void + /** + * Test-only scoped waterfall whose result can make the return trip. + * @param value - marker payload. + * @param next - delegates to the next listener. + * @returns the claimed or delegated value. + */ + 'meta-fixture/waterfall'( + this: Context, + request: MetaFixtureRequest, + next: () => Promise, + ): Promise /** * Test-only answered event, whose result no one-way delivery can return. * @param value - marker payload. @@ -35,12 +60,16 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertLookupMap { + metaFixture: TypertLookup + } + interface TypertContextMap { metaFixture: TypertContext } interface TypertRemoteEventSelection extends - Record<'meta-fixture/forwardable' | 'meta-fixture/absent', true> {} + Record<'meta-fixture/forwardable' | 'meta-fixture/waterfall' | 'meta-fixture/absent', true> {} } describe('typert-protocol Remote declarations', () => { @@ -55,6 +84,11 @@ describe('typert-protocol Remote declarations', () => { return value } + @Remote({ mode: 'stream' }) + *watch(): Iterable { + yield 'value' + } + @RemoteScope('metaFixture') scoped(value: string): string { return value @@ -78,6 +112,7 @@ describe('typert-protocol Remote declarations', () => { }) expect(remoteMethods(goals)).toEqual([ { method: 'create', invocation: { kind: 'direct' } }, + { method: 'watch', mode: 'stream', invocation: { kind: 'direct' } }, { method: 'scoped', invocation: { kind: 'context', context: 'metaFixture' } }, ]) await ctx.fiber.dispose() @@ -192,6 +227,8 @@ describe('typert-protocol Remote declarations', () => { expect(() => Remote('bad name')).toThrow('export name') expect(() => Remote('.')).toThrow('export name') expect(() => Remote('..')).toThrow('export name') + expect(() => Remote({ mode: 'unary' } as unknown as { mode: 'stream' })).toThrow('exactly mode') + expect(() => Remote({ mode: 'stream', extra: true } as unknown as { mode: 'stream' })).toThrow('exactly mode') expect(() => RemoteScope('' as 'metaFixture')).toThrow('Scope key') expect(() => RemoteScope('metaFixture', 'bad/name')).toThrow('export name') @@ -213,6 +250,12 @@ describe('typert-protocol Remote declarations', () => { Reflect.setPrototypeOf(prototypeLess, null) expect(() => { direct[0]!.call(prototypeLess) }).toThrow('without a prototype') + const stream: Array<(this: object) => void> = [] + Remote({ mode: 'stream' })(method, methodContext('run', stream)) + const conflict = Object.create({}) as object + direct[0]!.call(conflict) + expect(() => { stream[0]!.call(conflict) }).toThrow('conflicting invocation markers') + class Service { run(): void {} } @@ -236,14 +279,41 @@ describe('typert-protocol Remote declarations', () => { expect(() => bindTypertRemote({}, 'goals', { namespace: 'api goals' })).toThrow('namespace') }) - it('admits only one-way event shapes and only selected events that exist', () => { + it('admits notifications and same-result scoped waterfalls selected from Cordis Events', () => { expectTypeOf<'meta-fixture/forwardable'>().toExtend() + expectTypeOf<'meta-fixture/waterfall'>().toExtend() expectTypeOf<'meta-fixture/scoped'>().not.toExtend() expectTypeOf<'meta-fixture/answered'>().not.toExtend() expectTypeOf<'meta-fixture/forwardable'>().toExtend() + expectTypeOf<'meta-fixture/waterfall'>().toExtend() expectTypeOf<'meta-fixture/scoped'>().not.toExtend() expectTypeOf<'meta-fixture/absent'>().not.toExtend() + + expectTypeOf<{ event: 'meta-fixture/forwardable'; mode: 'emit' }>() + .toExtend() + expectTypeOf<{ event: 'meta-fixture/waterfall'; mode: 'waterfall' }>() + .toExtend() + expectTypeOf<{ event: 'meta-fixture/waterfall'; mode: 'emit' }>() + .not.toExtend() + }) + + it('derives Client Context arguments from the selected Cordis waterfall declaration', () => { + type ExpectedListener = ( + this: Context, + request: { + readonly agent: Context + readonly signal?: AbortSignal + readonly nested: readonly [{ readonly owner?: MetaFixtureSubject }] + readonly transform: (subject: MetaFixtureSubject) => Promise + }, + next: () => Promise, + ) => Promise + + expectTypeOf>() + .toEqualTypeOf() + expectTypeOf>() + .toEqualTypeOf<(value: string) => void>() }) }) diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index b3c081b128..fe01da8e7c 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/registry/README.md -README.md: fa227b1c8faf1abd5a6492d4b8fe7d0c51ceeef1 -README.zh.md: 62c5a2141de9ccdd564654658a464d721b77f97d +README.md: 2f3056735f5352c06b1e115a18c7ad1968ff90ae +README.zh.md: 770d3111fc65b08ee5d30ead6c3e6899630dd3e0 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index fa227b1c8f..2f3056735f 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -10,7 +10,7 @@ Package reflection is keyed by `#`. Schemas are keyed by `>() + private readonly hosts = new Map>() private readonly hostResolvers = new Map>() - private readonly clients = new Map>() + private readonly clients = new Map>() private readonly changes: ChangeSource constructor(report: ReportObserverError) { @@ -347,34 +347,51 @@ class ContextStore { return { registerHost: >( key: K, - provider: TypertHostContextProvider>, - ) => this.registerHost(ctx, key, provider), + adapter: TypertHostContextAdapter>, + ) => this.registerHost(ctx, key, adapter), configureHost: >( key: K, resolver: TypertHostContextResolver>, ) => this.configureHost(ctx, key, resolver), registerClient: >( key: K, - binder: TypertClientContextBinder>, - ) => this.registerClient(ctx, key, binder), + adapter: TypertClientContextAdapter>, + ) => this.registerClient(ctx, key, adapter), + identifyHost: context => this.identifyHost(context), getHost: key => this.getHost(key), getClient: key => this.clients.get(key)?.provider, subscribe: listener => this.changes.subscribe(ctx, listener), } } - private getHost(key: string): TypertHostContextProvider | undefined { - const provider = this.hosts.get(key)?.provider - if (provider === undefined) return undefined + private getHost(key: string): TypertHostContextAdapter | undefined { + const adapter = this.hosts.get(key)?.provider + if (adapter === undefined) return undefined const resolver = this.hostResolvers.get(key)?.provider - if (resolver === undefined) return provider + if (resolver === undefined) return adapter return { - wire: provider.wire, - wireTypeSymbol: provider.wireTypeSymbol, + wire: adapter.wire, + wireTypeSymbol: adapter.wireTypeSymbol, + identity: context => adapter.identity(context), resolve: id => resolver.resolve(id), } } + private identifyHost(ctx: Context): ReturnType { + let match: ReturnType + for (const key of this.hosts.keys()) { + const identity = this.getHost(key)?.identity(ctx) + if (identity === undefined) continue + if (match !== undefined) { + throw new Error( + `typert: Host Context is recognized by both ${JSON.stringify(match.kind)} and ${JSON.stringify(key)}`, + ) + } + match = { kind: key, identity } + } + return match + } + private configureHost( ctx: Context, key: string, @@ -399,16 +416,16 @@ class ContextStore { }, `typert.contexts.configureHost(${JSON.stringify(key)})`) } - private registerHost(ctx: Context, key: string, provider: TypertHostContextProvider): TypertDisposer { + private registerHost(ctx: Context, key: string, adapter: TypertHostContextAdapter): TypertDisposer { validateSegment('Context key', key) - validateWireName('Context wire field', provider.wire) - validateNonempty('Context wire type symbol', provider.wireTypeSymbol) - return this.registerProvider(ctx, this.hosts, 'host-context', key, provider) + validateWireName('Context wire field', adapter.wire) + validateNonempty('Context wire type symbol', adapter.wireTypeSymbol) + return this.registerProvider(ctx, this.hosts, 'host-context', key, adapter) } - private registerClient(ctx: Context, key: string, binder: TypertClientContextBinder): TypertDisposer { + private registerClient(ctx: Context, key: string, adapter: TypertClientContextAdapter): TypertDisposer { validateSegment('Context key', key) - return this.registerProvider(ctx, this.clients, 'client-context', key, binder) + return this.registerProvider(ctx, this.clients, 'client-context', key, adapter) } private registerProvider( @@ -484,7 +501,7 @@ export class TypertRegistry extends Service implements TypertRegistryContract { return this.lookupStore.view(this.ctx) } - /** Host Context providers and Client Context binders. */ + /** Host and Client Context adapters. */ get contexts(): TypertContextRegistry { return this.contextStore.view(this.ctx) } diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 2128a186a7..546063b0de 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -22,6 +22,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertContextMap { registryFixture: TypertContext + registryFixtureOther: TypertContext } } @@ -331,10 +332,12 @@ describe('TypertRegistry', () => { const disposeHost = ctx.typert.contexts.registerHost('registryFixture', { wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', + identity: candidate => candidate === scoped ? object.id : undefined, resolve: id => id === object.id ? scoped : undefined, }) const disposeClient = ctx.typert.contexts.registerClient('registryFixture', { identity: candidate => candidate === scoped ? object.id : undefined, + resolve: id => id === object.id ? scoped : undefined, }) expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object) @@ -345,8 +348,15 @@ describe('TypertRegistry', () => { hostTypeSymbol: '@fixture/agent#Agent', wireTypeSymbol: '@fixture/session#SessionId', }]) + expect(ctx.typert.contexts.getHost('registryFixture')?.identity(scoped)).toBe('agent-1') expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped) expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1') + expect(ctx.typert.contexts.getClient('registryFixture')?.resolve('agent-1')).toBe(scoped) + expect(ctx.typert.contexts.identifyHost(scoped)).toEqual({ + kind: 'registryFixture', + identity: 'agent-1', + }) + expect(ctx.typert.contexts.identifyHost(ctx)).toBeUndefined() await Promise.all([disposeClient(), disposeHost(), disposeLookup()]) expect(ctx.typert.lookups.keys()).toEqual([]) @@ -355,6 +365,26 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) + it('rejects a Host Context recognized by more than one registered kind', async () => { + const ctx = await makeCtx() + const scoped = ctx.extend() + ctx.typert.contexts.registerHost('registryFixture', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === scoped ? 'first' : undefined, + resolve: () => undefined, + }) + ctx.typert.contexts.registerHost('registryFixtureOther', { + wire: 'otherAgentId', + wireTypeSymbol: '@fixture#OtherAgentId', + identity: candidate => candidate === scoped ? 'second' : undefined, + resolve: () => undefined, + }) + + expect(() => ctx.typert.contexts.identifyHost(scoped)) + .toThrow('recognized by both "registryFixture" and "registryFixtureOther"') + }) + it('configures an asynchronous lookup resolver independently of provider load order', async () => { const ctx = await makeCtx() const fallback = { id: 'fallback' } @@ -400,9 +430,11 @@ describe('TypertRegistry', () => { const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', { wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', + identity: candidate => candidate === fallback ? 'fallback' : undefined, resolve: id => id === 'fallback' ? fallback : undefined, }) await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) + expect(ctx.typert.contexts.getHost('registryFixture')?.identity(fallback)).toBe('fallback') expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured') await disposeProvider() @@ -410,6 +442,7 @@ describe('TypertRegistry', () => { const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', { wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', + identity: candidate => candidate === fallback ? 'fallback' : undefined, resolve: id => id === 'fallback' ? fallback : undefined, }) await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) @@ -438,9 +471,13 @@ describe('TypertRegistry', () => { const host = { wire: 'agentId', wireTypeSymbol: '@fixture#AgentId', + identity: (_candidate: Context) => undefined, + resolve: () => undefined, + } + const client = { + identity: (_candidate: Context) => undefined, resolve: () => undefined, } - const client = { identity: () => undefined } const disposeLookup = ctx.typert.lookups.register('fixture', lookup) const disposeHost = ctx.typert.contexts.registerHost('registryFixture', host) const disposeClient = ctx.typert.contexts.registerClient('registryFixture', client) diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index 555e94d45d..fcdbf049bf 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -6,7 +6,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** * Identifies one workspace record. A generated uuid, never the path: path diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddeacead94..9311ceec08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -382,6 +382,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../packages/client/modules + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../packages/client/store '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../../packages/client/ui-primitives @@ -885,10 +888,16 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + ws: + specifier: ^8.21.0 + version: 8.21.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection @@ -901,12 +910,21 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 zod: specifier: ^4.4.3 version: 4.4.3 packages/api/remotes: dependencies: + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -914,15 +932,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../workspace-controller '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -953,18 +974,140 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session/session-persistence '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../context/session-reference '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval + '@deepseek-ai/dsh-user-questions': + specifier: workspace:^ + version: link:../../interaction/user-questions + + packages/api/session-controller: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-default-model': + specifier: workspace:^ + version: link:../../core/agent-default-model + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-permission-presets': + specifier: workspace:^ + version: link:../../interaction/permission-presets + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session/session-projection-cache + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../../session-query/session-query + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session/session-title + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace + + packages/api/workspace-controller: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace packages/attachment/attachment: devDependencies: @@ -1397,6 +1540,12 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot @@ -1412,18 +1561,21 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../client/ui-agent-preset + '@deepseek-ai/dsh-client-ui-approval': + specifier: workspace:^ + version: link:../../client/ui-approval '@deepseek-ai/dsh-client-ui-attachment': specifier: workspace:^ version: link:../../client/ui-attachment '@deepseek-ai/dsh-client-ui-brand-official': specifier: workspace:^ version: link:../../client/ui-brand-official + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../../client/ui-chat '@deepseek-ai/dsh-client-ui-commands': specifier: workspace:^ version: link:../../client/ui-commands @@ -1472,6 +1624,9 @@ importers: '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../../client/ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../client/ui-settings @@ -1620,9 +1775,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - ws: - specifier: ^8.21.0 - version: 8.21.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1654,9 +1806,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 packages/client/hmr: dependencies: @@ -1695,15 +1844,18 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -1738,7 +1890,7 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/client/runtime: + packages/client/store: dependencies: immer: specifier: ^10.1.1 @@ -1750,66 +1902,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-attachment': - specifier: workspace:^ - version: link:../../attachment/attachment - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../interaction/commands - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-llm-retry': - specifier: workspace:^ - version: link:../../llm/llm-retry - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session/session-projection - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../session/session-title - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - '@deepseek-ai/dsh-tool-todo': - specifier: workspace:^ - version: link:../../todo/tool-todo - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/client/ui-agent-preset: devDependencies: @@ -1819,15 +1914,18 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -1837,15 +1935,75 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-approval: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -1865,15 +2023,18 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-ui-chat': specifier: workspace:^ - version: link:../runtime + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -1898,15 +2059,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../ui-sidebar @@ -1926,6 +2087,90 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-chat: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-ui-approval': + specifier: workspace:^ + version: link:../ui-approval + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@deepseek-ai/dsh-compaction': + specifier: workspace:^ + version: link:../../compaction/compaction + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-stats': + specifier: workspace:^ + version: link:../../session/session-stats + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-commands: dependencies: clsx: @@ -1938,15 +2183,18 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -1959,6 +2207,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -1968,6 +2222,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -1987,57 +2244,63 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-compaction': - specifier: workspace:^ - version: link:../../compaction/compaction '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry @@ -2047,12 +2310,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode - '@deepseek-ai/dsh-session-projection': + '@deepseek-ai/dsh-session': specifier: workspace:^ - version: link:../../session/session-projection - '@deepseek-ai/dsh-session-stats': - specifier: workspace:^ - version: link:../../session/session-stats + version: link:../../core/session '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -2062,12 +2322,12 @@ importers: '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2086,24 +2346,30 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2123,18 +2389,21 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2162,9 +2431,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ - version: link:../runtime + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../ui-workspace @@ -2192,21 +2461,30 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2247,18 +2525,30 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2268,6 +2558,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2280,12 +2573,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2295,6 +2588,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2316,9 +2615,15 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2349,18 +2654,24 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2370,6 +2681,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -2401,15 +2715,18 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2425,12 +2742,24 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2446,15 +2775,18 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2467,6 +2799,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -2497,9 +2835,6 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2509,6 +2844,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2518,6 +2859,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2612,9 +2956,6 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger @@ -2643,9 +2984,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2671,6 +3009,36 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-session: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-settings: dependencies: '@deepseek-ai/schemastery': @@ -2686,9 +3054,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2729,15 +3097,21 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -2774,15 +3148,18 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -2810,15 +3187,15 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -2859,15 +3236,18 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -2893,12 +3273,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2908,9 +3288,18 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -2929,15 +3318,15 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -2947,6 +3336,9 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2956,6 +3348,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2974,6 +3369,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -2986,12 +3384,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -3004,12 +3405,21 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -3050,15 +3460,18 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings @@ -3093,24 +3506,33 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3145,21 +3567,33 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3169,6 +3603,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -3200,33 +3637,45 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../interaction/user-questions @@ -3242,21 +3691,33 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../ui-chat '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3288,15 +3749,21 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../runtime + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -3306,6 +3773,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../ui-sidebar @@ -3315,6 +3788,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3333,6 +3809,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../modules + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -4327,6 +4806,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../api/gateway '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules @@ -4363,9 +4845,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ - version: link:../../client/runtime + version: link:../../client/ui-renderer '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme @@ -4466,15 +4948,18 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../../client/ui-input-trigger '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../../client/ui-session '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar @@ -4490,6 +4975,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -5115,9 +5603,9 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model - '@deepseek-ai/dsh-api-remotes': + '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ - version: link:../../api/remotes + version: link:../../api/session-controller '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment @@ -5136,33 +5624,24 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm '@deepseek-ai/dsh-native-command': specifier: workspace:^ version: link:../../util/native-command + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session/session-projection - '@deepseek-ai/dsh-session-projection-cache': - specifier: workspace:^ - version: link:../../session/session-projection-cache '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../session/session-title '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -5172,21 +5651,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../interaction/user-approval - '@deepseek-ai/dsh-user-questions': - specifier: workspace:^ - version: link:../../interaction/user-questions '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto - '@deepseek-ai/dsh-workspace': - specifier: workspace:^ - version: link:../../workspace/workspace '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -5203,18 +5670,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets - '@deepseek-ai/dsh-cordis-host-runner': - specifier: workspace:^ - version: link:../../extensions/cordis-host-runner '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-storage': - specifier: workspace:^ - version: link:../../storage/storage - '@deepseek-ai/dsh-storage-domain': - specifier: workspace:^ - version: link:../../storage/storage-domain '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -5517,6 +5975,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope packages/jobs/jobs: devDependencies: @@ -6384,9 +6845,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-client-store': specifier: workspace:^ - version: link:../../client/runtime + version: link:../../client/store '@deepseek-ai/dsh-client-ui-commands': specifier: workspace:^ version: link:../../client/ui-commands @@ -6396,6 +6857,12 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../../client/ui-session '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../../client/ui-slots @@ -8340,21 +8807,45 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': + '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ - version: link:../../client/runtime + version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-controller': + specifier: workspace:^ + version: link:../../api/workspace-controller + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-client-ui-chat': + specifier: workspace:^ + version: link:../../client/ui-chat + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../../client/ui-conversation '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../../client/ui-session + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../../client/ui-settings '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../../client/ui-slots - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -8450,9 +8941,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-host-apiproxy': + '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ - version: link:../../host/apiproxy + version: link:../../api/session-controller '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 4278624bca..8eb9886503 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -63,9 +63,9 @@ describe('client bundle purity gate', () => { const resolveId = purityResolveId() it('leaves default externals and non-scoped specifiers alone', () => { + expect(resolveId('@deepseek-ai/dsh-client-store')).toBeNull() expect(resolveId('@deepseek-ai/dsh-client-ui-slots')).toBeNull() expect(resolveId('@deepseek-ai/dsh-client-ui-primitives')).toBeNull() - expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull() expect(resolveId('react')).toBeNull() expect(resolveId('zod')).toBeNull() }) @@ -95,14 +95,14 @@ describe('client bundle purity gate', () => { it('throws on cross-plugin value imports — bare plugin names and /client subpaths alike', () => { expect(() => resolveId('@deepseek-ai/dsh-client-connection')).toThrow(/purity/) - expect(() => resolveId('@deepseek-ai/dsh-client-runtime')).toThrow(/purity/) + expect(() => resolveId('@deepseek-ai/dsh-client-ui-session')).toThrow(/purity/) expect(() => resolveId('@deepseek-ai/dsh-client-ui-layout/client')).toThrow(/purity/) }) - it('admits the parser-preloaded runtime for every dynamic bundle', () => { - expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull() + it('admits package-specific requests only for the declaring bundle', () => { + expect(resolveId('@deepseek-ai/dsh-api-session-controller/client')).toBeNull() const withoutRequest = purityResolveId('@deepseek-ai/dsh-client-ui-goal') - expect(withoutRequest('@deepseek-ai/dsh-client-runtime/client')).toBeNull() + expect(() => withoutRequest('@deepseek-ai/dsh-api-session-controller/client')).toThrow(/purity/) }) it('externalizes the baseline independently of each package manifest', () => { @@ -114,7 +114,7 @@ describe('client bundle purity gate', () => { expect(requesting.neverBundle('react')).toBe(true) expect(requesting.neverBundle('zod')).toBe(false) expect(plain.neverBundle('react')).toBe(true) - expect(plain.neverBundle('@deepseek-ai/dsh-client-runtime/client')).toBe(true) + expect(plain.neverBundle('@deepseek-ai/dsh-client-store')).toBe(true) }) }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 6b19d4220f..d8bed47236 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -92,6 +92,7 @@ export const SERVICE_PAGE: Record = { sessionReferenceResolver: 'session-reference.md', sessionProjectionCache: 'session-projection.md', sessionProjections: 'session-projection.md', + sessionController: 'session.md', sessions: 'session.md', settings: 'settings.md', sessionTitle: 'session-title.md', @@ -115,6 +116,7 @@ export const SERVICE_PAGE: Record = { workflowEngine: 'workflow.md', webhookRuntime: 'webhook.md', workspaceRegistry: 'workspace.md', + workspaceController: 'workspace.md', } /** @@ -146,13 +148,14 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract', connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API', uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', + uiSession: 'client-side Session source adapter — packages/client/ui-session/README.md owns the API', + uiConversation: 'client-side Conversation registries and assembler — packages/client/ui-conversation/README.md owns the API', + uiWorkspace: 'client-side Workspace navigation adapter — packages/client/ui-workspace/README.md owns the API', settingsSchema: 'client-side schema introspection service — packages/client/ui-settings/README.md owns the API', settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API', - chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API', + chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-chat/README.md owns the API', commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API', - conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the API', - conversationViews: 'client-side interface-typed registry — packages/client/runtime/README.md owns the API', layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API', locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API', modelDirectories: 'client-side interface-typed browser service — packages/client/ui-model-selection/README.md owns the API', @@ -161,9 +164,9 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { sessionLogDownload: 'client-side browser download controller — packages/session-query/session-log-export/README.md owns the API', inputTriggers: 'client-side interface-typed browser service — packages/client/ui-input-trigger/README.md owns the API', timer: 'client-side dynamic-package timer service — packages/extensions/cordis-client-runner/README.md owns the API', - slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the API', + slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', - workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the API', + workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', } /** @@ -177,6 +180,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'agent': 'core.md', 'agent-loop': 'core.md', 'agent-preset': 'core.md', + 'api-session': 'session.md', 'approval': 'approval.md', 'commands': 'commands.md', 'cordis': 'extensions.md', @@ -193,6 +197,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'system-prompt': 'system-prompt.md', 'session-telemetry': 'session-telemetry.md', 'tools': 'tools.md', + 'user-questions': 'user-questions.md', 'webserver': 'web-server.md', 'workflow': 'workflow.md', } @@ -209,13 +214,13 @@ export const EVENT_SCOPE_PAGE: Record = { */ export const EVENT_WALK_EXEMPTIONS: Record = { 'command/executed': 'client-face local command acknowledgment — packages/client/ui-commands/README.md owns the API', - 'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the API', + 'connection/reset': 'client-face transport signal — packages/api/session-controller/README.md owns the API', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', - 'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the API', + 'slots/changed': 'client-face slot invalidation signal — packages/client/ui-renderer/README.md owns the API', 'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the API', } @@ -279,6 +284,7 @@ export const LINK_MAP: Readonly> = { MessageFeedbackVersion: 'feedback.md', MessageFeedbackVersionConflict: 'feedback.md', UserMessage: 'session.md', + ApiSessionAgentResult: 'session.md', PreStepDecision: 'core.md', PreStepContext: 'core.md', RequestErrorAction: 'core.md', @@ -288,15 +294,46 @@ export const LINK_MAP: Readonly> = { SessionReferenceCandidate: 'session-reference.md', SessionReferenceMentionCandidate: 'session-reference.md', SessionReferenceInput: 'session-reference.md', + SessionAttachmentRequest: 'session.md', + SessionAttachmentValue: 'session.md', + SessionCancelRequest: 'session.md', + SessionCancelValue: 'session.md', + SessionControlFrame: 'session.md', + SessionCreateRequest: 'session.md', + SessionCreateValue: 'session.md', SessionEvent: 'session.md', + SessionFollowFrame: 'session.md', + SessionFollowRequest: 'session.md', + SessionForkRequest: 'session.md', + SessionForkValue: 'session.md', SessionId: 'core.md', + SessionListRequest: 'session.md', + SessionListValue: 'session.md', + SessionModels: 'session.md', + SessionModelsRequest: 'session.md', + SessionPage: 'session.md', + SessionPageRequest: 'session.md', + SessionPromptRequest: 'session.md', + SessionPromptValue: 'session.md', + SessionRenameRequest: 'session.md', + SessionRenameValue: 'session.md', + SessionRespondReceipt: 'session.md', + SessionRespondRequest: 'session.md', + SessionSearchValue: 'session.md', + SessionSelectModelRequest: 'session.md', + SessionSelectModelValue: 'session.md', + SessionSummary: 'session.md', + SessionUpdateQueueRequest: 'session.md', + SessionUpdateQueueValue: 'session.md', SessionStartSource: 'core.md', SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', ApprovalOutcome: 'approval.md', ApprovalPolicy: 'approval.md', ApprovalRequest: 'approval.md', + ApprovalRequestEvent: 'approval.md', ApprovalService: 'approval.md', + AskUserQuestionRequestEvent: 'user-questions.md', EncodedImageAttachment: 'attachment.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', @@ -521,7 +558,19 @@ export const LINK_MAP: Readonly> = { DomainChanged: 'storage.md', DomainFacility: 'storage.md', Workspace: 'workspace.md', + WorkspaceArchiveSessionRequest: 'workspace.md', + WorkspaceArchiveValue: 'workspace.md', + WorkspaceCreateRequest: 'workspace.md', + WorkspaceCreateValue: 'workspace.md', + WorkspaceDeleteRequest: 'workspace.md', + WorkspaceDeleteValue: 'workspace.md', + WorkspaceFollowFrame: 'workspace.md', WorkspaceId: 'workspace.md', + WorkspaceInsertBeforeRequest: 'workspace.md', + WorkspaceInsertSessionBeforeRequest: 'workspace.md', + WorkspaceOrderValue: 'workspace.md', + WorkspaceRenameRequest: 'workspace.md', + WorkspaceValue: 'workspace.md', WebBootGraph: 'client-modules.md', SessionTelemetryRecord: 'session-telemetry.md', WorkflowRunInfo: 'workflow.md', @@ -534,6 +583,7 @@ export const LINK_MAP: Readonly> = { ProjectionCheckpoint: 'session-projection.md', DirectoryPickerCapability: 'workspace.md', TypertContribution: 'invariants.md', + TypertRemoteEventSource: 'typert.md', TypertFace: 'invariants.md', TypertPackageFilter: 'invariants.md', TypertPackageRecord: 'invariants.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1aeb42b436..79407ece40 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -149,6 +149,21 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, + { + key: 'sessionController', + pkg: 'api-session-controller', + title: 'Host Session Remote controller', + mode: 'core', + consumers: ['apiproxy'], + note: 'Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains.', + }, + { + key: 'workspaceController', + pkg: 'api-workspace-controller', + title: 'Host Workspace Remote controller', + mode: 'core', + note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.', + }, { key: 'invariants', pkg: 'invariants', diff --git a/scripts/package-graph.spec.ts b/scripts/package-graph.spec.ts new file mode 100644 index 0000000000..fecbccedfe --- /dev/null +++ b/scripts/package-graph.spec.ts @@ -0,0 +1,51 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectPackageGraph } from './package-graph.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(packages: Readonly>): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-package-graph-')) + roots.push(root) + for (const [name, dependencies] of Object.entries(packages)) { + const directory = join(root, 'packages', 'client', name) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'package.json'), `${JSON.stringify({ + name: `@deepseek-ai/dsh-${name}`, + peerDependencies: Object.fromEntries(dependencies.map(dependency => [ + `@deepseek-ai/dsh-${dependency}`, + 'workspace:^', + ])), + }, null, 2)}\n`) + } + return root +} + +describe('collectPackageGraph', () => { + it('orders packages after their dependencies', () => { + const root = fixture({ application: ['feature'], feature: ['foundation'], foundation: [] }) + + expect(collectPackageGraph(root, ['client'], 'fixture').map(pkg => pkg.short)) + .toEqual(['foundation', 'feature', 'application']) + }) + + it('keeps a dependency cycle together and before its consumers', () => { + const root = fixture({ consumer: ['left'], left: ['right'], right: ['left'], foundation: [] }) + + expect(collectPackageGraph(root, ['client'], 'fixture').map(pkg => pkg.short)) + .toEqual(['foundation', 'left', 'right', 'consumer']) + }) + + it('rejects a missing in-repo peer', () => { + const root = fixture({ consumer: ['missing'] }) + + expect(() => collectPackageGraph(root, ['client'], 'fixture')) + .toThrow('fixture: @deepseek-ai/dsh-consumer references missing in-repo peer @deepseek-ai/dsh-missing') + }) +}) diff --git a/scripts/package-graph.ts b/scripts/package-graph.ts index 0853b5c1ca..3d12a904b1 100644 --- a/scripts/package-graph.ts +++ b/scripts/package-graph.ts @@ -25,11 +25,12 @@ export interface PackageGraphNode { } /** - * Read every harness package manifest and return dependency-safe graph nodes. + * Read every harness package manifest and return dependency-first graph nodes. * @param root - absolute repository root. * @param groupOrder - caller-specific tiebreak order for packages in the same dependency layer. * @param gate - command name used in structural error messages. - * @returns package nodes ordered after all of their in-repo dependencies. + * @returns package nodes ordered after their in-repo dependencies, except for + * stable back edges inside a dependency cycle. */ export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] { const packages: PackageGraphNode[] = [] @@ -57,14 +58,28 @@ export function collectPackageGraph(root: string, groupOrder: readonly string[], } function topoSort(packages: PackageGraphNode[], groupOrder: readonly string[], gate: string): PackageGraphNode[] { - const remaining = new Map(packages.map(pkg => [pkg.short, pkg])) + const byName = new Map(packages.map(pkg => [pkg.short, pkg])) + for (const pkg of packages) { + for (const dependency of pkg.deps) { + if (!byName.has(dependency)) { + throw new Error(`${gate}: ${pkg.name} references missing in-repo peer ${SCOPE}${dependency}`) + } + } + } + const remaining = new Map(byName) const placed = new Set() const out: PackageGraphNode[] = [] while (remaining.size > 0) { - const ready = [...remaining.values()] + let ready = [...remaining.values()] .filter(pkg => pkg.deps.every(dep => placed.has(dep))) .sort((a, b) => comparePackages(a, b, groupOrder)) - if (ready.length === 0) throw new Error(`${gate}: dependency cycle among ${[...remaining.keys()].join(', ')}`) + if (ready.length === 0) { + const cycle = sinkCycles(remaining) + .map(component => component.sort((a, b) => comparePackages(a, b, groupOrder))) + .sort((a, b) => comparePackages(a[0], b[0], groupOrder))[0] + if (cycle === undefined) throw new Error(`${gate}: could not order package dependency graph`) + ready = cycle + } for (const pkg of ready) { out.push(pkg) placed.add(pkg.short) @@ -74,6 +89,66 @@ function topoSort(packages: PackageGraphNode[], groupOrder: readonly string[], g return out } +type PackageGraphComponent = [PackageGraphNode, ...PackageGraphNode[]] + +function sinkCycles(remaining: ReadonlyMap): PackageGraphComponent[] { + let nextIndex = 0 + const indices = new Map() + const lowLinks = new Map() + const stack: PackageGraphNode[] = [] + const stacked = new Set() + const components: PackageGraphComponent[] = [] + + const visit = (pkg: PackageGraphNode): void => { + const index = nextIndex + nextIndex += 1 + indices.set(pkg.short, index) + lowLinks.set(pkg.short, index) + stack.push(pkg) + stacked.add(pkg.short) + for (const dependency of pkg.deps) { + const target = remaining.get(dependency) + if (target === undefined) continue + if (!indices.has(target.short)) { + visit(target) + lowLinks.set(pkg.short, Math.min(requiredValue(lowLinks, pkg.short), requiredValue(lowLinks, target.short))) + } else if (stacked.has(target.short)) { + lowLinks.set(pkg.short, Math.min(requiredValue(lowLinks, pkg.short), requiredValue(indices, target.short))) + } + } + if (lowLinks.get(pkg.short) !== indices.get(pkg.short)) return + const first = stack.pop() + if (first === undefined) throw new Error('package graph traversal lost its active component') + stacked.delete(first.short) + const component: PackageGraphComponent = [first] + let member = first + while (member !== pkg) { + const next = stack.pop() + if (next === undefined) throw new Error('package graph traversal lost its active component') + stacked.delete(next.short) + component.push(next) + member = next + } + components.push(component) + } + + for (const pkg of remaining.values()) { + if (!indices.has(pkg.short)) visit(pkg) + } + return components.filter((component) => { + const names = new Set(component.map(pkg => pkg.short)) + const first = component[0] + const cyclic = component.length > 1 || first.deps.includes(first.short) + return cyclic && component.every(pkg => pkg.deps.every(dep => !remaining.has(dep) || names.has(dep))) + }) +} + +function requiredValue(values: ReadonlyMap, key: K): V { + const value = values.get(key) + if (value === undefined) throw new Error('package graph traversal lost an indexed node') + return value +} + function comparePackages(a: PackageGraphNode, b: PackageGraphNode, groupOrder: readonly string[]): number { const groupA = groupOrder.indexOf(a.group) const groupB = groupOrder.indexOf(b.group) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 134dfad203..ed264e49e2 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -202,12 +202,12 @@ const EXACT_EDITS: readonly ExactEdit[] = [ "@deepseek-ai/.+" ] }, - "packages/util/home": {`, + "packages/host/directory-picker-auto": {`, replace: ` "ignoreDependencies": [ "@deepseek-ai/.+" ] }, - "packages/util/home": {`, + "packages/host/directory-picker-auto": {`, expect: 1, }, { @@ -345,7 +345,7 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ id: 'vendoring-cookbook-name-invariant-zh', file: 'docs/cookbook/adding-a-vendored-package.zh.md', find: '保留上游的 `name`/`version`/`exports`/`type`', - replace: '改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`', + replace: '改写 `name` 的 scope([映射](../rescope.zh.md)),保留上游的 `version`/`exports`/`type`', expect: 1, }, { diff --git a/scripts/run-oxlint.spec.ts b/scripts/run-oxlint.spec.ts index 85628382e8..07ee0b3954 100644 --- a/scripts/run-oxlint.spec.ts +++ b/scripts/run-oxlint.spec.ts @@ -18,7 +18,7 @@ describe('Oxlint invocation', () => { it('uses location-preserving diagnostics in CI', () => { expect(resolveOxlintInvocation(['.'], { CI: 'true', DSH_OXLINT_THREADS: '4' })).toEqual({ - args: ['.', '--format=unix', '--threads=4'], + args: ['.', '--format=default', '--threads=4'], env: { CI: 'true', DSH_OXLINT_THREADS: '4', GOMAXPROCS: '4' }, }) }) diff --git a/scripts/run-oxlint.ts b/scripts/run-oxlint.ts index bcbddb5011..335bf213ed 100644 --- a/scripts/run-oxlint.ts +++ b/scripts/run-oxlint.ts @@ -32,7 +32,7 @@ export interface OxlintInvocation { */ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.ProcessEnv): OxlintInvocation { const resolvedArgs = [...args] - if (env.CI === 'true' && !hasOutputFormat(args)) resolvedArgs.push('--format=unix') + if (env.CI === 'true' && !hasOutputFormat(args)) resolvedArgs.push('--format=default') const raw = env.DSH_OXLINT_THREADS if (raw === undefined || raw === '') return { args: resolvedArgs, env: { ...env } } const parsed = Number.parseInt(raw, 10) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 30a98799b6..06cce11143 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -129,7 +129,13 @@ { "doc": "docs/subsystems/core.md", "symbol": "Agent", - "source": "packages/core/agent/src/runtime-types.ts" + "source": "packages/core/agent/src/types.ts", + "augmentations": [ + { + "source": "packages/core/agent/src/runtime-types.ts", + "module": "./types.ts" + } + ] }, { "doc": "docs/subsystems/core.md", @@ -870,11 +876,6 @@ "symbol": "AskUserQuestionAnswer", "source": "packages/interaction/user-questions/src/types.ts" }, - { - "doc": "docs/subsystems/user-questions.md", - "symbol": "UserQuestionProvider", - "source": "packages/interaction/user-questions/src/index.ts" - }, { "doc": "docs/subsystems/user-questions.md", "symbol": "UserQuestionError", @@ -1974,6 +1975,11 @@ "doc": "docs/subsystems/feedback.md", "symbol": "MessageFeedbackDeleteResult", "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "packages/client/ui-conversation/README.md", + "symbol": "ComposerChainProps", + "source": "packages/client/ui-conversation/src/client/contract/slots.ts" } ] } diff --git a/scripts/verify-client-packages.spec.ts b/scripts/verify-client-packages.spec.ts index ca378f4b59..df12891476 100644 --- a/scripts/verify-client-packages.spec.ts +++ b/scripts/verify-client-packages.spec.ts @@ -100,7 +100,7 @@ describe('source package uses', () => { describe('package modes', () => { it('accepts one dynamic package and one statically linked package', () => { - const dynamic = pkg('runtime') + const dynamic = pkg('feature') const shell = pkg('ui-slots', { dynamic: false, staticLinked: true }) expect(collectClientPackageViolations(facts([dynamic, shell]))).toEqual([]) }) @@ -116,11 +116,11 @@ describe('package modes', () => { it('requires seeded workspace packages to use staticLinked and preloads to name dynamic rows', () => { const slots = declaration('ui-slots', { dynamic: false }) - const runtime = declaration('runtime', { dynamic: false }) + const bootstrap = declaration('bootstrap', { dynamic: false }) const found = collectClientPackageViolations(facts([], { - declarations: [slots, runtime], + declarations: [slots, bootstrap], platformModules: [slots.name], - preloadedExternals: [runtime.name + '/client'], + preloadedExternals: [bootstrap.name + '/client'], })) expect(found).toHaveLength(2) expect(found.join('\n')).toContain('does not use the staticLinked preset') @@ -128,14 +128,14 @@ describe('package modes', () => { }) it('requires every preloaded external to have a parser preload row', () => { - const runtime = declaration('runtime') + const bootstrap = declaration('bootstrap') expect(collectClientPackageViolations(facts([], { - declarations: [runtime], - preloadedExternals: [runtime.name + '/client'], + declarations: [bootstrap], + preloadedExternals: [bootstrap.name + '/client'], parserPreloadIds: [], }))).toEqual([ 'packages/client/web/src/platform.ts: parser-preloaded external ' - + '"@deepseek-ai/dsh-client-runtime/client" has no matching PARSER_PRELOAD_IDS row in ' + + '"@deepseek-ai/dsh-client-bootstrap/client" has no matching PARSER_PRELOAD_IDS row in ' + 'packages/client/modules/src/index.ts', ]) }) @@ -144,12 +144,12 @@ describe('package modes', () => { describe('dependency sections', () => { it('accepts dynamic peer plus dev relationships, static dev inputs, and private dependencies', () => { const slots = pkg('ui-slots', { dynamic: false, staticLinked: true }) - const runtime = pkg('runtime', { + const conversation = pkg('conversation', { inject: ['@deepseek-ai/dsh-client-feature'], sourceUses: { - '@deepseek-ai/dsh-agent': ['packages/client/runtime/src/index.ts'], - '@deepseek-ai/dsh-client-ui-slots': ['packages/client/runtime/src/client/slots.ts'], - react: ['packages/client/runtime/src/client/view.tsx'], + '@deepseek-ai/dsh-agent': ['packages/client/conversation/src/index.ts'], + '@deepseek-ai/dsh-client-ui-slots': ['packages/client/conversation/src/client/slots.ts'], + react: ['packages/client/conversation/src/client/view.tsx'], }, dependencies: { immer: '^10.1.1' }, peerDependencies: { @@ -165,7 +165,7 @@ describe('dependency sections', () => { react: '^18.2.0', }, }) - expect(collectClientPackageViolations(facts([slots, runtime], { + expect(collectClientPackageViolations(facts([slots, conversation], { platformModules: ['react', slots.name], }))).toEqual([]) }) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 2cc5dcd2a8..c6cabd41e6 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -68,14 +68,17 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/store': { kind: 'none', reason: 'Browser-side state primitives; register nothing model-facing.' }, 'packages/client/ui-renderer': { kind: 'none', reason: 'Browser-side render assembly; registers nothing model-facing.' }, + 'packages/client/ui-session': { kind: 'none', reason: 'Browser-side Session adapter; registers nothing model-facing.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, - 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' }, - 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-brand-official': { kind: 'none', reason: 'Browser-side presentation occupants; registers nothing model-facing.' }, 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-approval': { kind: 'none', reason: 'Browser-side approval presentation; registers nothing model-facing.' }, + 'packages/client/ui-chat': { kind: 'none', reason: 'Browser-side Chat presentation; registers nothing model-facing.' }, 'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over the message-feedback sidecar; ratings and notes never enter the Session log, model context, or telemetry.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' }, @@ -155,6 +158,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' }, + 'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' }, 'packages/typert/protocol': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers nothing model-facing.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/jobs/jobs': { kind: 'indirect', reason: 'Producer and controller plugins own all model rendering over the job registry.' }, diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 56d7e25cf5..96146cc260 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -28,6 +28,13 @@ interface ManifestEntry { symbol: string /** Source file (repo-relative) that exports the symbol. */ source: string + /** Explicit module augmentations whose members complete an interface. */ + augmentations?: Array<{ + /** Source file (repo-relative) containing the augmentation. */ + source: string + /** String-literal module specifier containing the merged interface. */ + module: string + }> /** Complete declaration (default), or a body-stripped public class API. */ projection?: 'public-api' } @@ -133,6 +140,67 @@ function sourceDeclaration(sourceRel: string, symbol: string): string | null { return null } +interface InterfacePart { + text: string + sourceFile: ts.SourceFile + declaration: ts.InterfaceDeclaration +} + +/** Find one top-level interface declaration in a source file. */ +function sourceInterface(sourceRel: string, symbol: string): InterfacePart | null { + const abs = resolve(root, sourceRel) + const text = readFileSync(abs, 'utf8') + const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) + const declaration = sourceFile.statements.find((statement): statement is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(statement) && statement.name.text === symbol, + ) + return declaration === undefined ? null : { text, sourceFile, declaration } +} + +/** Find one interface declaration inside an explicit string-literal module augmentation. */ +function augmentedInterface(sourceRel: string, moduleName: string, symbol: string): InterfacePart | null { + const abs = resolve(root, sourceRel) + const text = readFileSync(abs, 'utf8') + const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) + for (const statement of sourceFile.statements) { + if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) + || statement.name.text !== moduleName || !statement.body || !ts.isModuleBlock(statement.body)) continue + const declaration = statement.body.statements.find((member): member is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(member) && member.name.text === symbol, + ) + if (declaration !== undefined) return { text, sourceFile, declaration } + } + return null +} + +/** Render one interface plus explicitly named module augmentations as its merged declaration. */ +function mergedInterfaceDeclaration(entry: ManifestEntry): string | null { + const base = sourceInterface(entry.source, entry.symbol) + if (base === null) return null + const additions: InterfacePart[] = [] + for (const augmentation of entry.augmentations ?? []) { + const part = augmentedInterface(augmentation.source, augmentation.module, entry.symbol) + if (part === null) return null + additions.push(part) + } + + const parts = [base, ...additions] + const docs = parts.map(part => sourceJSDoc(part.text, part.declaration)).filter(Boolean) + const typeParameters = base.declaration.typeParameters + ?.map(parameter => parameter.getText(base.sourceFile)).join(', ') + const heritage = parts.flatMap(part => + part.declaration.heritageClauses?.map(clause => clause.getText(part.sourceFile)) ?? [], + ).join(' ') + const header = `interface ${entry.symbol}${typeParameters ? `<${typeParameters}>` : ''}${heritage ? ` ${heritage}` : ''} {` + const members = parts.flatMap(part => part.declaration.members.map((member) => { + const jsDoc = sourceJSDoc(part.text, member) + const declaration = part.text.slice(member.getStart(part.sourceFile), member.getEnd()) + return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}` + })) + const declaration = [header, ...members.map(member => member.split('\n').map(line => ` ${line}`).join('\n')), '}'].join('\n') + return docs.length === 0 ? declaration : `${docs.join('\n')}\n${declaration}` +} + /** Leading source JSDoc attached to one declaration or member. */ function sourceJSDoc(text: string, node: ts.Node): string { return ts.getJSDocCommentsAndTags(node) @@ -270,9 +338,11 @@ let verified = 0 for (const e of entries) { const b = blockByKey.get(keyOf(e)) if (!b) continue // already reported as an orphan entry - const decl = e.projection === 'public-api' - ? sourcePublicApi(e.source, e.symbol) - : sourceDeclaration(e.source, e.symbol) + const decl = e.augmentations !== undefined + ? mergedInterfaceDeclaration(e) + : e.projection === 'public-api' + ? sourcePublicApi(e.source, e.symbol) + : sourceDeclaration(e.source, e.symbol) if (decl === null) { errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`) continue diff --git a/tsconfig.base.json b/tsconfig.base.json index b92fdb61e9..195cb5d398 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,6 +47,15 @@ "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"], "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], + "@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"], + "@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"], + "@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"], + "@deepseek-ai/dsh-api-session-controller/types": ["./packages/api/session-controller/src/types.ts"], + "@deepseek-ai/dsh-api-session-controller/remote-events": ["./packages/api/session-controller/src/remote-events.ts"], + "@deepseek-ai/dsh-api-workspace-controller": ["./packages/api/workspace-controller/src/index.ts"], + "@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"], + "@deepseek-ai/dsh-api-workspace-controller/invariant": ["./packages/api/workspace-controller/src/invariant.ts"], + "@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"], "@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"], "@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], @@ -61,6 +70,7 @@ "@deepseek-ai/dsh-tool-todo/client": ["./packages/todo/tool-todo/src/client.ts"], "@deepseek-ai/dsh-session-title/types": ["./packages/session/session-title/src/types.ts"], "@deepseek-ai/dsh-session-title/client": ["./packages/session/session-title/src/client.ts"], + "@deepseek-ai/dsh-workspace/types": ["./packages/workspace/workspace/src/types.ts"], "@deepseek-ai/dsh-session-stats/types": ["./packages/session/session-stats/src/types.ts"], "@deepseek-ai/dsh-session-stats/client": ["./packages/session/session-stats/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], @@ -187,16 +197,19 @@ "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], + "@deepseek-ai/dsh-client-store": ["./packages/client/store/src/index.ts"], + "@deepseek-ai/dsh-client-store/invariant": ["./packages/client/store/src/invariant.ts"], "@deepseek-ai/dsh-client-ui-renderer": ["./packages/client/ui-renderer/src"], "@deepseek-ai/dsh-client-ui-renderer/client": ["./packages/client/ui-renderer/src/client"], "@deepseek-ai/dsh-client-ui-renderer/invariant": ["./packages/client/ui-renderer/src/invariant.ts"], + "@deepseek-ai/dsh-client-ui-session": ["./packages/client/ui-session/src"], + "@deepseek-ai/dsh-client-ui-session/client": ["./packages/client/ui-session/src/client"], + "@deepseek-ai/dsh-client-ui-session/invariant": ["./packages/client/ui-session/src/invariant.ts"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], "@deepseek-ai/dsh-client-modules": ["./packages/client/modules/src"], - "@deepseek-ai/dsh-client-runtime": ["./packages/client/runtime/src"], - "@deepseek-ai/dsh-client-runtime/client": ["./packages/client/runtime/src/client"], "@deepseek-ai/dsh-cordis-client-runner": ["./packages/extensions/cordis-client-runner/src"], "@deepseek-ai/dsh-cordis-client-runner/client": ["./packages/extensions/cordis-client-runner/src/client"], "@deepseek-ai/dsh-cordis-client-runner/invariant": ["./packages/extensions/cordis-client-runner/src/invariant.ts"], @@ -208,7 +221,12 @@ "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-brand-official": ["./packages/client/ui-brand-official/src"], + "@deepseek-ai/dsh-client-ui-approval": ["./packages/client/ui-approval/src"], + "@deepseek-ai/dsh-client-ui-approval/client": ["./packages/client/ui-approval/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-chat": ["./packages/client/ui-chat/src"], + "@deepseek-ai/dsh-client-ui-chat/client": ["./packages/client/ui-chat/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], + "@deepseek-ai/dsh-client-ui-conversation/client": ["./packages/client/ui-conversation/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"], "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b5874b6fde..291b813300 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -42,13 +42,11 @@ // smoke policy). webserver has zero workspace deps and no cordis merge, // so it cannot drag host-side Context augmentation into this program. { "path": "./packages/host/webserver" }, - // Compaction seam: the client-runtime pin test value-imports the canonical - // checkpoint const from the cordis-free dsh-compaction/checkpoint leaf and - // deliberately never loads the dsh-compaction package root or the host-side - // Context merges reachable through it. The client package pins the same - // leaf through a type-only import in transcript-adapter.ts; composite - // rootDir rules make both paths depend on this runtime project reference. + // Compaction seam: ui-chat type-imports the canonical checkpoint contract + // from the cordis-free dsh-compaction/checkpoint leaf. Composite rootDir + // rules make that path depend on this project reference. { "path": "./packages/compaction/compaction" }, + { "path": "./packages/client/store" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/ui-attachment" }, { "path": "./packages/client/ui-primitives" }, @@ -57,14 +55,17 @@ { "path": "./packages/client/connection/tsconfig.client.json" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, + { "path": "./packages/api/session-controller/tsconfig.client.json" }, + { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, - { "path": "./packages/client/runtime" }, { "path": "./packages/extensions/cordis-client-runner" }, { "path": "./packages/extensions/ui-cordis" }, { "path": "./packages/test-support/client-runtime" }, { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-brand-official" }, + { "path": "./packages/client/ui-approval" }, + { "path": "./packages/client/ui-chat" }, { "path": "./packages/client/ui-conversation" }, { "path": "./packages/client/ui-tool" }, { "path": "./packages/client/ui-deliverables" }, @@ -95,6 +96,7 @@ { "path": "./packages/client/ui-settings-plugin-inventory" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/ui-renderer" }, + { "path": "./packages/client/ui-session" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } ] diff --git a/tsconfig.host.json b/tsconfig.host.json index c3ead24d3c..35258110d5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -144,6 +144,8 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, + { "path": "./packages/api/session-controller/tsconfig.host.json" }, + { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, { "path": "./packages/session/session-checkpoint-policy" }, diff --git a/vitest.config.ts b/vitest.config.ts index 7ac8101425..f24c8c552e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -208,14 +208,26 @@ export default defineConfig({ 'packages/client/ui-primitives/src/RiskConfirmation.tsx', 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', + 'packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx', 'packages/client/ui-renderer/src/client/*', - // This isolated settings-scope lifecycle has complete unit coverage; - // keep it out of the broader client-runtime GUI debt exemption. - 'packages/client/runtime/src/**/!(settings-scope).ts', + // Session object internals retain the runtime GUI debt exemption; the + // new Controller entry, transport, Agent scope, and adapters stay gated. + 'packages/api/session-controller/src/client/sessions/*', + 'packages/api/session-controller/src/client/ordered-baseline.ts', + 'packages/api/session-controller/src/client/time-zone.ts', // Keep the browser conversation tree under its existing GUI debt // exemption while gating the newly stateful Host half and vocabulary. 'packages/client/ui-conversation/src/client/*', 'packages/client/ui-conversation/src/invariant.ts', + // Chat presentation and assembly retain the same GUI debt exemption; + // package wiring and the new approval-detail adapter remain gated. + 'packages/client/ui-chat/src/client/chat/!(ApprovalCommand).{ts,tsx}', + 'packages/client/ui-chat/src/client/conversation-nodes/*', + 'packages/client/ui-chat/src/client/details/*', + 'packages/client/ui-chat/src/client/model/*', + 'packages/client/ui-chat/src/client/contract/context-provenance.ts', + 'packages/client/ui-chat/src/client/contract/snapshot.ts', + 'packages/client/ui-chat/src/client/historical-images.ts', 'packages/client/ui-primitives/src/DisclosureRow.tsx', 'packages/client/ui-tool/src/*', 'packages/client/ui-slots/src/*',