refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -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/runtime/README.md
|
||||
README.md: 441f5462d98c8a9e92ebc9f08cdd7568e89fd427
|
||||
README.zh.md: f4ec9ac53c10f8f375c837556db7623c95d11c17
|
||||
README.md: 1fb91bc8ca1bf9beae0ea12632acd9572db58670
|
||||
README.zh.md: 294a77d7081f5b475aca8b50f6c6d8322370375d
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService 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. Contract: api-contracts v3 §4. 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.
|
||||
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. Contract: api-contracts v3 §4. 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.
|
||||
|
||||
@@ -16,25 +16,25 @@ The callback returns one synchronous disposer or an iterable of disposers. A gen
|
||||
|
||||
## 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. `WorkspacesService.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.
|
||||
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.
|
||||
|
||||
`WorkspacesService.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.
|
||||
`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. `WorkspacesService.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.
|
||||
`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.
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
`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.tasksBySession` mirrors the Host's `session/tasks` 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.
|
||||
`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.
|
||||
|
||||
`SessionsService.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.
|
||||
`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
|
||||
|
||||
`WorkspacesService.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. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
`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.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService,拥有 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 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
客户端 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 销毁。约定:api-contracts v3 §4。每个 `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 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替。
|
||||
|
||||
@@ -16,25 +16,25 @@
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspacesService.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
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 的请求。
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
|
||||
`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` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
|
||||
`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 中。
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。
|
||||
SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。
|
||||
|
||||
`indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
|
||||
|
||||
`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。
|
||||
`SessionListState.jobsBySession` 按 last-wins 镜像宿主的 `session/jobs` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
`SessionRuntime.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.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 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
`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 恢复。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-runtime",
|
||||
"description": "Client core services: SlotsService, SessionsService (scope tree + object layer)",
|
||||
"description": "Client core services: SlotRegistry, SessionRuntime (scope tree + object layer)",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
@@ -62,7 +62,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -70,7 +70,7 @@
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@types/react": "~18.3.1"
|
||||
},
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
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-type-meta'
|
||||
import type { TypertClientRemote, TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */
|
||||
export type AgentContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'agent'>
|
||||
readonly remote: TypertClientRemote & TypertRemoteScopeApi<'agent'>
|
||||
}
|
||||
|
||||
/** Context tag written by {@link createScope}. */
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
|
||||
import type {
|
||||
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { ConversationSnapshot } from '../sessions/conversation.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Cross-domain sessions face: the contract surface sibling domains (today:
|
||||
* workspaces) consume instead of the sessions implementation. The sessions
|
||||
* domain satisfies it structurally — SessionsService is assignable, checked
|
||||
* 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.
|
||||
|
||||
@@ -5,12 +5,12 @@ import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/c
|
||||
// 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-type-meta'
|
||||
import type { TypertContext } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import { SlotRegistry } from './slots.ts'
|
||||
import { SessionRuntime } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { WorkspacesService } from './workspaces/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'
|
||||
@@ -18,7 +18,7 @@ import { ConversationViewRegistry } from './conversation/view-registry.ts'
|
||||
|
||||
export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
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'
|
||||
@@ -36,7 +36,7 @@ export type {
|
||||
} from './contract/conversation.ts'
|
||||
export type { ConversationRuntime } from './sessions/conversation-assembler.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.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'
|
||||
// The provide channel is shared with the client test runtime (one
|
||||
@@ -45,7 +45,7 @@ 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, WorkspacesService } from './workspaces/service.ts'
|
||||
export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts'
|
||||
export { 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).
|
||||
@@ -60,7 +60,7 @@ export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
|
||||
export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
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 {
|
||||
@@ -111,10 +111,10 @@ 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-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertContextMap {
|
||||
/** Client Agent scope identity; the agent and session share one wire id. */
|
||||
agent: TypeRTContext<SessionId>
|
||||
agent: TypertContext<SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
'connection/reset'(): void
|
||||
}
|
||||
interface Context {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
slots: import('./slots.ts').SlotRegistry
|
||||
/** Event-to-business-Context Definition registry. */
|
||||
conversationEvents: import('./conversation/event-registry.ts').ConversationEventRegistry
|
||||
/** Per-target Conversation snapshot builder registry. */
|
||||
@@ -179,24 +179,24 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the wire handle and Client TypeRT registry. */
|
||||
/** 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(SlotsService)
|
||||
ctx.plugin(SlotRegistry)
|
||||
const conversation = {
|
||||
events: new ConversationEventRegistry(ctx),
|
||||
views: new ConversationViewRegistry(ctx),
|
||||
}
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api, ctx.remote, conversation)
|
||||
const sessions = new SessionRuntime(ctx, connection.api, ctx.remote, conversation)
|
||||
ctx.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => sessions.scopeOf(candidate),
|
||||
})
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
const workspaces = new WorkspaceRuntime(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
() => workspaces.startInitialSelection(),
|
||||
'runtime: initial Workspace selection',
|
||||
|
||||
@@ -79,7 +79,7 @@ export function contextProvenance(source: unknown): ContextProvenanceView {
|
||||
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
|
||||
// Workspace instructions name the files they were reconciled from, which
|
||||
// identifies the producer far better than the plugin id would.
|
||||
case 'workspace-instructions':
|
||||
case 'agent-instructions':
|
||||
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
|
||||
case 'plugin':
|
||||
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
|
||||
|
||||
@@ -216,10 +216,10 @@ export interface CompactionSummaryNode {
|
||||
seq: number
|
||||
/** Unix epoch ms of the checkpoint event. */
|
||||
time: number
|
||||
/** Summary text from the checkpoint's cited `compact/summary` event; null when
|
||||
/** Summary text from the checkpoint's cited `compaction/summary` event; null when
|
||||
* the window cut left that event outside (the marker is then not expandable). */
|
||||
summary: string | null
|
||||
/** Seq of the loaded `compact/summary` event, or null when that event is outside the window. */
|
||||
/** Seq of the loaded `compaction/summary` event, or null when that event is outside the window. */
|
||||
summaryEventSeq: number | null
|
||||
/** Number of surface items replaced, or null when the summary event is unavailable or malformed. */
|
||||
shadowedItemCount: number | null
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// SessionManager: the instance cluster Map<SessionId, Session> (lazy-built, resident) + the frame
|
||||
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
|
||||
// dispatch entry + list state, constructed and held by SessionRuntime (one per client runtime).
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId,
|
||||
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.
|
||||
@@ -49,8 +49,8 @@ export interface SessionListSnapshot {
|
||||
phase: SessionListPhase
|
||||
error: RpcError | null
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
/** Background tasks per session; an absent key is an empty set. */
|
||||
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
|
||||
/** Background jobs per session; an absent key is an empty set. */
|
||||
jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ function bufferedRequestKey(envelope: RpcRequest<MuxFrame>): string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Match ui-question's binary plan-review routing at the wire boundary. */
|
||||
/** Match ui-user-questions's binary plan-review routing at the wire boundary. */
|
||||
function questionInteractionStatus(
|
||||
questions: Extract<MuxFrame, { type: 'question/requested' }>['questions'],
|
||||
): PendingInteractionStatus {
|
||||
@@ -143,10 +143,10 @@ export class SessionManager {
|
||||
private readonly openCatalogs = new Set<SessionId>()
|
||||
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
|
||||
/**
|
||||
* Background tasks per session, last-wins from `session/tasks`. An empty set
|
||||
* Background jobs per session, last-wins from `session/jobs`. An empty set
|
||||
* is stored as an absent key, so absence and `[]` are one representation.
|
||||
*/
|
||||
private readonly tasksBySession = new Map<SessionId, readonly TaskView[]>()
|
||||
private readonly jobsBySession = new Map<SessionId, readonly JobView[]>()
|
||||
|
||||
private selected: SessionId | undefined
|
||||
|
||||
@@ -702,12 +702,12 @@ export class SessionManager {
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/tasks') {
|
||||
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.tasks.length === 0) this.tasksBySession.delete(frame.sessionId)
|
||||
else this.tasksBySession.set(frame.sessionId, frame.tasks)
|
||||
if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId)
|
||||
else this.jobsBySession.set(frame.sessionId, frame.jobs)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
@@ -718,7 +718,7 @@ export class SessionManager {
|
||||
// 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.tasksBySession.delete(frame.sessionId)
|
||||
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
|
||||
@@ -833,7 +833,7 @@ export class SessionManager {
|
||||
// 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.tasksBySession.delete(frame.sessionId)
|
||||
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
|
||||
@@ -1070,7 +1070,7 @@ export class SessionManager {
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
subagentsByParent: Object.fromEntries(this.catalogs),
|
||||
tasksBySession: Object.fromEntries(this.tasksBySession),
|
||||
jobsBySession: Object.fromEntries(this.jobsBySession),
|
||||
currentAddress: current === undefined ? undefined : this.addresses.get(current),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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 — SessionsService drives it from wire
|
||||
* 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.
|
||||
@@ -71,7 +71,7 @@ export class SessionProvideChannel {
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider (see
|
||||
* SessionsService.provide for the product contract). Live bundles rebuild
|
||||
* 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* SessionRuntime: 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,7 +16,7 @@
|
||||
*/
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId,
|
||||
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.
|
||||
@@ -88,11 +88,11 @@ export interface SessionListState {
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
/**
|
||||
* Background tasks each session can see, mirrored last-wins from
|
||||
* `session/tasks`. A missing key is an empty set — the Host sends no baseline
|
||||
* 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.
|
||||
*/
|
||||
tasksBySession: Readonly<Record<SessionId, readonly TaskView[]>>
|
||||
jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
|
||||
/** Current session's catalog-derived address, absent on ordinary navigation. */
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
@@ -203,7 +203,7 @@ interface ScopeRecord {
|
||||
provideInfo: SessionProvideInfo
|
||||
}
|
||||
|
||||
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
|
||||
/** 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<string, HostObservable<unknown>>
|
||||
@@ -226,7 +226,7 @@ export interface SessionProvideDescriptor {
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
|
||||
export class SessionsService implements ISessions {
|
||||
export class SessionRuntime 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
|
||||
@@ -249,7 +249,7 @@ export class SessionsService implements ISessions {
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* SessionsService.open} / {@link SessionsService.clear}. Projection
|
||||
* SessionRuntime.open} / {@link SessionRuntime.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.
|
||||
@@ -301,7 +301,7 @@ export class SessionsService implements ISessions {
|
||||
)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
||||
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
@@ -474,7 +474,7 @@ export class SessionsService implements ISessions {
|
||||
/**
|
||||
* Create a session on the host. Resolution guarantee: by the time the
|
||||
* promise resolves, the created session is in the list store and
|
||||
* {@link SessionsService.binding} resolves it — callers (New Session
|
||||
* {@link SessionRuntime.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 +491,7 @@ export class SessionsService implements ISessions {
|
||||
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source (same
|
||||
* synchronous-addressability guarantee as {@link SessionsService.create}:
|
||||
* synchronous-addressability guarantee as {@link SessionRuntime.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
|
||||
@@ -557,7 +557,7 @@ export class SessionsService 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 SessionsService.scopeOf}.
|
||||
* {@link SessionRuntime.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
@@ -580,7 +580,7 @@ export class SessionsService implements ISessions {
|
||||
/**
|
||||
* Resolve one session's render-layer standard-props bundle (ctx never
|
||||
* enters the render layer; the renderer subscribes to
|
||||
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
|
||||
* {@link SessionRuntime.currentProvideInfo}). Pure resolution — render-safe:
|
||||
* no staging, no window side effects (StrictMode double-invokes and
|
||||
* concurrent discarded passes must stay free).
|
||||
*/
|
||||
@@ -659,7 +659,7 @@ export class SessionsService implements ISessions {
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const {
|
||||
items, current, phase, subagentsByParent, tasksBySession, currentAddress,
|
||||
items, current, phase, subagentsByParent, jobsBySession, currentAddress,
|
||||
} = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
@@ -729,7 +729,7 @@ export class SessionsService implements ISessions {
|
||||
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
||||
})
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress })
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress })
|
||||
this.pruneScopes()
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ 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-type-meta'
|
||||
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'
|
||||
@@ -125,7 +125,7 @@ export class Session implements SessionFace {
|
||||
private snapshotCache: ConversationSnapshot
|
||||
private readonly notifier: Notifier
|
||||
/**
|
||||
* Agent-scoped cordis context, bound once by SessionsService when it
|
||||
* 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
|
||||
@@ -162,7 +162,7 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the Agent-scoped context minted by SessionsService (single write;
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* SlotsService: the cordis Service layer of the slot system over the pure
|
||||
* SlotRegistry: the cordis Service layer of the slot system 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
|
||||
@@ -77,7 +77,7 @@ interface ErasedCore { register(options: object, component: unknown): () => void
|
||||
type SlotInjectionEffect = (() => void) | Iterable<() => void, void, void>
|
||||
|
||||
/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */
|
||||
export class SlotsService extends Service {
|
||||
export class SlotRegistry extends Service {
|
||||
private readonly _core = new SlotCore()
|
||||
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
|
||||
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
|
||||
@@ -409,8 +409,8 @@ export class SlotsService extends Service {
|
||||
// inside the class — see its JSDoc for why it must live on the prototype).
|
||||
// Element access reaches the private _register legally and keeps it a
|
||||
// TS-visible read.
|
||||
;(SlotsService.prototype as { register: (options: object, component: unknown) => () => void }).register
|
||||
= function register(this: SlotsService, rawOptions: object, component: unknown): () => void {
|
||||
;(SlotRegistry.prototype as { register: (options: object, component: unknown) => () => void }).register
|
||||
= function register(this: SlotRegistry, rawOptions: object, component: unknown): () => void {
|
||||
// The core's overloads proved the shares; the implementation works on
|
||||
// the erased view (same pattern as the core's own implementation arm).
|
||||
const options = rawOptions as ErasedRegisterOptions
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** WorkspacesService projects the Workspace object manager for UI consumers. */
|
||||
/** WorkspaceRuntime projects the Workspace object manager for UI consumers. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
@@ -48,7 +48,7 @@ export class DirectoryBrowseError extends Error {
|
||||
}
|
||||
|
||||
/** Real Workspace object layer and Host actions. */
|
||||
export class WorkspacesService implements IWorkspaces {
|
||||
export class WorkspaceRuntime implements IWorkspaces {
|
||||
/** UI-facing immutable projection; the manager remains wire truth. */
|
||||
readonly list: SnapshotStore<WorkspaceListState>
|
||||
/** Workspace baseline and frame owner. */
|
||||
|
||||
@@ -12,8 +12,8 @@ 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 { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.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 {
|
||||
@@ -58,7 +58,7 @@ 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 SlotsService
|
||||
// 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')
|
||||
@@ -66,8 +66,8 @@ describe('runtime client apply', () => {
|
||||
expect(sessions !== undefined).toBe(true)
|
||||
expect(workspaces !== undefined).toBe(true)
|
||||
// The bound the wire schema enforces, not a per-connection negotiation.
|
||||
expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
|
||||
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
|
||||
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.
|
||||
@@ -107,8 +107,8 @@ describe('runtime client apply', () => {
|
||||
bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
|
||||
await flushMicrotasks()
|
||||
|
||||
const sessions = bench.ctx.get('sessions') as SessionsService
|
||||
const workspaces = bench.ctx.get('workspaces') as WorkspacesService
|
||||
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')
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('runtime client apply', () => {
|
||||
|
||||
it('wires registry changes into resident Sessions during the runtime apply pass', async () => {
|
||||
const bench = await mount()
|
||||
const sessions = bench.ctx.get('sessions') as SessionsService
|
||||
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,
|
||||
|
||||
Binary file not shown.
@@ -730,7 +730,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
role: 'start',
|
||||
}
|
||||
}
|
||||
if ((event.type as string) === 'compact/start') {
|
||||
if ((event.type as string) === 'compaction/start') {
|
||||
return {
|
||||
id: (event.data as unknown as { compactionId: string }).compactionId,
|
||||
role: 'start',
|
||||
@@ -758,7 +758,7 @@ describe('ConversationNodeAssembler', () => {
|
||||
input(at(2, 'step/start', { turn: 1, step: 1 })),
|
||||
input(at(3, 'turn/start', { turn: 2 })),
|
||||
input(at(4, 'command/run', { commandId: 'command', name: 'x' })),
|
||||
input(at(5, 'compact/start', { compactionId: 'compact', turn: null })),
|
||||
input(at(5, 'compaction/start', { compactionId: 'compact', turn: null })),
|
||||
], false)
|
||||
assembler.flush()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
|
||||
} from '../src/client/contract/conversation.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { SessionRuntime } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
|
||||
|
||||
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
|
||||
@@ -145,7 +145,7 @@ describe('Conversation registries', () => {
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
|
||||
}) as never)
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const sessions = new SessionRuntime(ctx, api, fakeRemote())
|
||||
await sessions.refresh()
|
||||
await Promise.resolve()
|
||||
sessions.scope(sessionId)
|
||||
|
||||
@@ -105,9 +105,9 @@ export const ev = {
|
||||
...text === undefined ? {} : { text },
|
||||
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
|
||||
} }),
|
||||
/** A compaction's log-only `compact/summary` record. */
|
||||
/** A compaction's log-only `compaction/summary` record. */
|
||||
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
|
||||
at(seq, { type: 'compact/summary', data: {
|
||||
at(seq, { type: 'compaction/summary', data: {
|
||||
summary: text(summary),
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [start, end],
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import * as RuntimeInvariant from '../src/invariant.ts'
|
||||
import { SlotsService } from '../src/client/slots.ts'
|
||||
import { SlotRegistry } from '../src/client/slots.ts'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
await ctx.plugin(RuntimeInvariant).await()
|
||||
return ctx
|
||||
}
|
||||
@@ -24,7 +24,7 @@ describe('runtime 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()
|
||||
await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
|
||||
await ctx.plugin(SlotRegistry).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
|
||||
// A real registration bumps the version first and re-emits through
|
||||
// onMutate — the audit sees version > 0 and stays quiet. (Erased call:
|
||||
// the typed register face rides the wave-1 ui-slots types.)
|
||||
@@ -36,7 +36,7 @@ describe('runtime slots/changed invariant', () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emit(ctx, 'slots/changed', '') }).toThrow(/without a slot key/)
|
||||
expect(() => { emit(ctx, 'slots/changed', 42) }).toThrow(/without a slot key/)
|
||||
await ctx.plugin(SlotsService).await()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
// Hand-emitted key that never saw a mutation: version 0 → violation.
|
||||
expect(() => { emit(ctx, 'slots/changed', 'never-mutated') })
|
||||
.toThrow(/before any mutation bumped its version/)
|
||||
|
||||
@@ -1152,32 +1152,32 @@ describe('completed reminder', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('background-task mirror', () => {
|
||||
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, tasks: unknown[]) =>
|
||||
({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never })
|
||||
const tasksFrame = (sessionId: SessionId, jobs: unknown[]) =>
|
||||
({ rpcId: 't' as never, payload: { type: 'session/jobs', sessionId, 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 first = manager.getListSnapshot().tasksBySession
|
||||
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' })]))
|
||||
expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([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()]))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true)
|
||||
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true)
|
||||
manager.handleMuxEnvelope(tasksFrame(S1, []))
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => {
|
||||
@@ -1187,7 +1187,7 @@ describe('background-task mirror', () => {
|
||||
rpcId: 's' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 },
|
||||
})
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the rows when the session is removed, whichever stream lands first', () => {
|
||||
@@ -1195,7 +1195,7 @@ describe('background-task mirror', () => {
|
||||
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 } })
|
||||
expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false)
|
||||
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies list subscribers so an open header re-renders without a poll', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* SessionsService: list store projection (manager → {ids, byId, current}
|
||||
* 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
|
||||
* lifecycle (lazy mint / frozen survival / removed teardown with staged
|
||||
@@ -9,7 +9,7 @@
|
||||
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, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
import { SessionCreateError, SessionRuntime, scopeOf } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
@@ -17,13 +17,13 @@ const sid = (s: string): SessionId => s as SessionId
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
api: FakeApiClient
|
||||
svc: SessionsService
|
||||
svc: SessionRuntime
|
||||
}
|
||||
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const svc = new SessionsService(ctx, api, fakeRemote())
|
||||
const svc = new SessionRuntime(ctx, api, fakeRemote())
|
||||
return { ctx, api, svc }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* SlotsService terminal-design account:
|
||||
* SlotRegistry terminal-design account:
|
||||
* built-in 'root', the three load-time throws (duplicate declaration /
|
||||
* undeclared contribution / cross-scope store handle), the renderer installation
|
||||
* contract (double install / not installed / non-root key), store instance
|
||||
@@ -9,7 +9,7 @@ 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 { SlotsService } from '../src/client/slots.ts'
|
||||
import { SlotRegistry } from '../src/client/slots.ts'
|
||||
|
||||
// Test-only slot keys (merged so the typed entries/spec faces accept them).
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -36,13 +36,13 @@ interface ErasedService {
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
svc: SlotsService
|
||||
svc: SlotRegistry
|
||||
erased: ErasedService
|
||||
}
|
||||
|
||||
async function boot(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(SlotsService)
|
||||
const fiber = ctx.plugin(SlotRegistry)
|
||||
await fiber
|
||||
// Service accessor (ctx.get reads the reflect store, which Service-class
|
||||
// plugins do not write; the accessor is the product path).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { SessionRuntime } from '../src/client/sessions/service.ts'
|
||||
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
|
||||
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.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
|
||||
@@ -187,12 +187,12 @@ describe('WorkspaceManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacesService', () => {
|
||||
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 SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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'),
|
||||
@@ -219,8 +219,8 @@ describe('WorkspacesService', () => {
|
||||
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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[],
|
||||
}))
|
||||
@@ -278,8 +278,8 @@ describe('WorkspacesService', () => {
|
||||
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 SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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[],
|
||||
@@ -298,8 +298,8 @@ describe('WorkspacesService', () => {
|
||||
it('returns created Workspaces and preserves Host business errors', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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,
|
||||
}))
|
||||
@@ -317,8 +317,8 @@ describe('WorkspacesService', () => {
|
||||
it('passes native directory selection and cancellation through without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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 }))
|
||||
@@ -331,7 +331,7 @@ describe('WorkspacesService', () => {
|
||||
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote()))
|
||||
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)
|
||||
@@ -352,8 +352,8 @@ describe('WorkspacesService', () => {
|
||||
it('opens a filesystem path through the host without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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: {} }))
|
||||
@@ -363,8 +363,8 @@ describe('WorkspacesService', () => {
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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()
|
||||
@@ -379,7 +379,7 @@ describe('WorkspacesService', () => {
|
||||
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 WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote()))
|
||||
const workspaces = new WorkspaceRuntime(ctx, api, new SessionRuntime(ctx, api, fakeRemote()))
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('one'), workspace('two')] as never[],
|
||||
}))
|
||||
@@ -402,8 +402,8 @@ describe('WorkspacesService', () => {
|
||||
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 SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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')]),
|
||||
@@ -435,8 +435,8 @@ describe('WorkspacesService', () => {
|
||||
|
||||
const emptyCtx = new Context()
|
||||
const emptyApi = new FakeApiClient()
|
||||
const emptySessions = new SessionsService(emptyCtx, emptyApi, fakeRemote())
|
||||
const emptyWorkspaces = new WorkspacesService(emptyCtx, emptyApi, emptySessions)
|
||||
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()
|
||||
@@ -445,8 +445,8 @@ describe('WorkspacesService', () => {
|
||||
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 SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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 },
|
||||
@@ -491,8 +491,8 @@ describe('WorkspacesService', () => {
|
||||
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 SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
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)
|
||||
@@ -525,8 +525,8 @@ describe('startInitialSelection', () => {
|
||||
function bench() {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
const sessions = new SessionRuntime(ctx, api, fakeRemote())
|
||||
const workspaces = new WorkspaceRuntime(ctx, api, sessions)
|
||||
return { api, sessions, workspaces }
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact"
|
||||
"path": "../../compaction/compaction"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
@@ -48,10 +48,10 @@
|
||||
"path": "../../llm/llm-retry"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
|
||||
Reference in New Issue
Block a user