Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .gitignore # apps/cli/package.json # apps/cli/src/web.ts # docs/architecture.i18n.yaml # docs/config-catalog.md # docs/module-graph.md # packages/client/connection/README.md # packages/client/connection/src/client/api.ts # packages/client/connection/src/client/fixture.ts # packages/client/runtime/README.md # packages/client/runtime/src/client/sessions/service.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/src/client/workspaces/service.ts # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx # packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.module.css # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/host/apiproxy/src/api-proxy.ts # pnpm-lock.yaml
This commit is contained in:
6
packages/client/runtime/README.i18n.yaml
Normal file
6
packages/client/runtime/README.i18n.yaml
Normal file
@@ -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/runtime/README.md
|
||||
README.md: c9881e809e2249301855244e7969aa8fb562a5a2
|
||||
README.zh.md: db81108d9ed34cace68726b1a86349da46ec1c70
|
||||
@@ -1,6 +1,8 @@
|
||||
# @deepseek-ai/dsh-client-runtime
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, the latest successful host capability description, and page-local Session Intent state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, page-local Workspace Intent state, default-target derivation, and the cross-object New Session flow. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4.
|
||||
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/scope/history state, and the latest successful host capability description; 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 both managers. 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.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
@@ -8,9 +10,13 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
|
||||
|
||||
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.
|
||||
|
||||
## Session creation failures
|
||||
## New Session and the blank mirror
|
||||
|
||||
`SessionsService.create` accepts an optional caller-preallocated SessionId. It throws `SessionCreateError` on failure: `requestedSessionId` remains available after transport uncertainty, while `publishedSessionId` is set when `workspace-attach-failed` proves the Host published a real Session before attachment failed. For the New Session flow, the frontend Session object owns its retained prompt and advances it through attachment and send; a partially published Session keeps the same object and prompt while it appears as Ungrouped.
|
||||
`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`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `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.
|
||||
|
||||
## Code Mode sub-dispatch index
|
||||
|
||||
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
|
||||
|
||||
## Session title projection
|
||||
|
||||
@@ -27,5 +33,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — 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 (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — 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 (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. 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 (the empty-state P0 postmortem).
|
||||
|
||||
37
packages/client/runtime/README.zh.md
Normal file
37
packages/client/runtime/README.zh.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @deepseek-ai/dsh-client-runtime
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态,以及最新一次成功的宿主能力描述;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
## Code Mode 子调用索引
|
||||
|
||||
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
|
||||
|
||||
## Session 标题投影
|
||||
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。客户端运行时承载浏览器侧服务与 Session 对象层;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。
|
||||
70
packages/client/runtime/src/client/agents/scope.ts
Normal file
70
packages/client/runtime/src/client/agents/scope.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Client Agent-scope primitive: mint a Cordis context tagged with the owning
|
||||
* Agent's identity. The mechanism mirrors the host `dsh-scope` architecture
|
||||
* (no-op plugin fiber + context tag + `Context.filter` routing predicate);
|
||||
* the shape deliberately diverges: the filter lives on the actx itself
|
||||
* instead of a separate carrier object, so scoped dispatch is plain cordis —
|
||||
* `actx.bail(actx, event, payload)` / `actx.emit(actx, ...)` — with no
|
||||
* wrapper. The host needs a detached carrier because its dispatch subject is
|
||||
* the business Agent object; client scope events carry only ids, so the
|
||||
* actx is the natural subject. The second divergence stands: the scope key
|
||||
* is the branded `SessionId` (value compared), not an object identity — the
|
||||
* agent and its session share one id (1:1, same axis; no separate AgentId
|
||||
* brand), and a client scope's identity IS that wire id. Third divergence,
|
||||
* deliberate: the client scopes the Agent IDENTITY, not a live Agent object
|
||||
* — a cold session's host Agent is already disposed while its client actx
|
||||
* stays alive for history viewing.
|
||||
*/
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Context tag written by {@link createScope}. */
|
||||
const kScope = Symbol('dsh.client.scope')
|
||||
|
||||
/** A minted Agent scope and its disposal boundary. */
|
||||
export interface AgentScopeHandle {
|
||||
/**
|
||||
* Tagged context: scope-owned registrations and scoped dispatch both go
|
||||
* through it (passing it as the dispatch subject routes to this agent's
|
||||
* tagged listeners plus every untagged one).
|
||||
*/
|
||||
ctx: Context
|
||||
/** Backing fiber (dispose tears down every scope-owned registration). */
|
||||
fiber: Fiber
|
||||
}
|
||||
|
||||
/** Shared no-op plugin backing each Agent scope fiber. */
|
||||
function agentScope(): void {}
|
||||
|
||||
/**
|
||||
* Mint an Agent scope under `ctx`: a no-op plugin fiber whose context
|
||||
* carries the agent tag and the dispatch filter — untagged listeners are
|
||||
* admitted globally, tagged listeners only for a matching agent.
|
||||
* Registrations through the returned ctx dispose with the fiber.
|
||||
* @param ctx - client root context the scope fiber mounts under.
|
||||
* @param key - owning agent identity (the routing tag; agent id === session id).
|
||||
* @returns the tagged context and its backing fiber.
|
||||
*/
|
||||
export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
|
||||
const fiber = ctx.plugin(agentScope)
|
||||
return {
|
||||
fiber,
|
||||
ctx: fiber.ctx.extend({
|
||||
[kScope]: key,
|
||||
[CordisContext.filter](listenerCtx: Context): boolean {
|
||||
const tag = scopeOf(listenerCtx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the nearest agent tag inherited by a context.
|
||||
* @param ctx - any client context.
|
||||
* @returns its agent identity (the session id), or undefined for root contexts.
|
||||
*/
|
||||
export function scopeOf(ctx: Context): SessionId | undefined {
|
||||
return (ctx as Context & { [kScope]?: SessionId })[kScope]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
@@ -11,10 +11,14 @@ import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './se
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
|
||||
export type { SessionIntentListSnapshot, SessionListPhase } from './sessions/manager.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase } from './sessions/manager.ts'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -24,8 +28,8 @@ export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
@@ -56,6 +60,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
}
|
||||
/** Standard kit for slots that remain mounted while current session changes. */
|
||||
interface SessionMaybeStandardProps {
|
||||
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
|
||||
/** Current session id; absent in the no-session state. */
|
||||
sessionId: SessionId | undefined
|
||||
}
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
useSessions: SnapshotSelectorHook<SessionListState>
|
||||
@@ -72,6 +82,20 @@ declare module 'cordis' {
|
||||
* @param key - the mutated SlotMap key.
|
||||
*/
|
||||
'slots/changed'(key: string): void
|
||||
/**
|
||||
* The host command registry changed (host/commands-changed passthrough).
|
||||
* Pure invalidation signal: subscribers refetch `command.list` in the
|
||||
* background rather than diffing.
|
||||
* @mode emit
|
||||
*/
|
||||
'commands/changed'(): 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').SlotsService
|
||||
@@ -91,16 +115,24 @@ export function apply(ctx: Context): void {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(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)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches) subscribe on ctx.
|
||||
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
},
|
||||
onDescription: (description) => { sessions.handleDescription(description) },
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
ctx.emit('connection/reset')
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId,
|
||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
@@ -134,6 +134,21 @@ export type ConversationNode =
|
||||
| ToolResultNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
* One `run_code` sub-dispatch materialized in the native call-block shapes so
|
||||
* every consumer (tool rows, details panel) renders it through the exact
|
||||
* components that render a native call: a started-but-unsettled sub-call is a
|
||||
* {@link RunningToolCall} (rows derive the running state from the shape,
|
||||
* exactly as for native calls) and its `tool/code-dispatch` settlement
|
||||
* replaces it in place with the {@link ToolResultNode} form. Never part of
|
||||
* the surface `nodes` flow — sub-calls live under their parent via
|
||||
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
|
||||
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
|
||||
* and its JSON-stringified logged arguments; `content`/`isError` are the
|
||||
* settled sub-call's complete logged outcome.
|
||||
*/
|
||||
export type CodeSubCall = RunningToolCall | ToolResultNode
|
||||
|
||||
/** In-flight tool card material: tool/call seen, tool/result not yet. */
|
||||
export interface RunningToolCall {
|
||||
callId: string
|
||||
@@ -148,6 +163,12 @@ export interface RunningToolCall {
|
||||
}
|
||||
|
||||
|
||||
/** One queued-message row mirrored from `session/queued` frames (key: the enqueueing prompt's rpcId when wire-sourced). */
|
||||
export interface QueuedMessage {
|
||||
readonly key: string
|
||||
readonly preview: string
|
||||
}
|
||||
|
||||
/** In-progress assistant output (chunk accumulator product). */
|
||||
export interface PartialAssistant {
|
||||
turn: number
|
||||
@@ -186,30 +207,6 @@ export interface PromptError {
|
||||
error: RpcError
|
||||
}
|
||||
|
||||
/** Workspace target of a frontend-only Session. */
|
||||
export type SessionIntentTarget =
|
||||
| { kind: 'workspace'; workspaceId: WorkspaceId }
|
||||
| { kind: 'workspace-intent' }
|
||||
|
||||
/** Publication state owned by a frontend Session before it joins the Host. */
|
||||
export interface SessionIntentSnapshot {
|
||||
target: SessionIntentTarget
|
||||
phase: 'ready' | 'connecting'
|
||||
error?: { step: 'session'; message: string }
|
||||
}
|
||||
|
||||
/** One editable prompt retained by its Session until the Host accepts it. */
|
||||
export interface PendingPrompt {
|
||||
text: string
|
||||
phase: 'editing' | 'sending' | 'failed'
|
||||
/** Failed prerequisite retried before sending, or the send itself. */
|
||||
retry: 'connect' | 'send'
|
||||
/** Workspace needed when retrying Session attachment. */
|
||||
workspaceId?: WorkspaceId
|
||||
/** Last failure diagnostic, absent while editing or sending. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
|
||||
export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
@@ -219,7 +216,16 @@ export interface ConversationSnapshot {
|
||||
foldDegraded: boolean
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
/**
|
||||
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
|
||||
* order. Populated from in-window `tool/code-dispatch` events (live and
|
||||
* replay identically); the per-parent array reference is stable across
|
||||
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
|
||||
*/
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
pending: readonly PendingInteraction[]
|
||||
/** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
|
||||
queue: readonly QueuedMessage[]
|
||||
running: boolean
|
||||
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
|
||||
composerPhase: ComposerPhase
|
||||
@@ -230,9 +236,16 @@ export interface ConversationSnapshot {
|
||||
hasMore: boolean
|
||||
loadingOlder: boolean
|
||||
promptError: PromptError | null
|
||||
/** Frontend-only publication state; null for a Host-connected Session. */
|
||||
intent: SessionIntentSnapshot | null
|
||||
/** Session-owned editable prompt waiting for connection, attachment, or send. */
|
||||
pendingPrompt: PendingPrompt | 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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SessionListEntry {
|
||||
title?: string
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
cwd?: string
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { Session } from './session.ts'
|
||||
import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation.ts'
|
||||
|
||||
/**
|
||||
* List arrival lifecycle, orthogonal to the pull-activity `state` axis:
|
||||
@@ -23,19 +22,11 @@ import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation.
|
||||
*/
|
||||
export type SessionListPhase = 'pending' | 'ready'
|
||||
|
||||
/** Session-owned frontend Intent projected into the global list snapshot. */
|
||||
export interface SessionIntentListSnapshot extends SessionIntentSnapshot {
|
||||
sessionId: SessionId
|
||||
prompt: string
|
||||
}
|
||||
|
||||
/** Immutable session-list snapshot for useSessionList. */
|
||||
export interface SessionListSnapshot {
|
||||
items: readonly SessionListEntry[]
|
||||
/** Selected real or frontend-only Session id. */
|
||||
/** Selected Session id (validated against items; masked to undefined while its session is off the list). */
|
||||
current: SessionId | undefined
|
||||
/** Sole page-local frontend Session projection; its state remains owned by Session. */
|
||||
intent: SessionIntentListSnapshot | undefined
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
|
||||
phase: SessionListPhase
|
||||
@@ -46,6 +37,8 @@ type SessionListMutation =
|
||||
| { kind: 'upsert'; summary: SessionSummary }
|
||||
| { kind: 'remove'; sessionId: SessionId }
|
||||
| { kind: 'status'; sessionId: SessionId; running: boolean }
|
||||
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
|
||||
| { kind: 'engaged'; sessionId: SessionId }
|
||||
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
@@ -76,8 +69,6 @@ export class SessionManager {
|
||||
private listMutations: SessionListMutation[] | null = null
|
||||
|
||||
private selected: SessionId | undefined
|
||||
private intentSessionId: SessionId | undefined
|
||||
private stopIntentWatch: (() => void) | undefined
|
||||
|
||||
private listSnapshotCache: SessionListSnapshot
|
||||
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
|
||||
@@ -101,80 +92,38 @@ export class SessionManager {
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
}
|
||||
|
||||
// ---- Selection and client-local intents ----
|
||||
// ---- Selection ----
|
||||
|
||||
/**
|
||||
* Select a real Session and discard the unmaterialized intent.
|
||||
* @param sessionId - listed real Session id.
|
||||
* Select a listed Session.
|
||||
* @param sessionId - listed Session id.
|
||||
*/
|
||||
select(sessionId: SessionId): void {
|
||||
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
|
||||
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
||||
}
|
||||
this.discardIntent()
|
||||
this.selected = sessionId
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/** Clear selection and abandon any frontend-only Session. */
|
||||
/** Clear the selection (the layout falls to the no-session view state). */
|
||||
clearSelection(): void {
|
||||
this.discardIntent()
|
||||
this.selected = undefined
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a frontend Session against a real or still-local Workspace target.
|
||||
* @param target - real Workspace or the WorkspacesService-owned local target.
|
||||
* @param prompt - optional prompt retained when retargeting from a picker.
|
||||
* @returns the frontend Session object that owns the Intent.
|
||||
*/
|
||||
startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
||||
this.discardIntent()
|
||||
const sessionId = `client-session-${crypto.randomUUID()}` as SessionId
|
||||
const session = this.createSession(sessionId, { target, prompt })
|
||||
this.sessions.set(sessionId, session)
|
||||
this.intentSessionId = sessionId
|
||||
this.selected = sessionId
|
||||
this.stopIntentWatch = session.subscribe(() => {
|
||||
if (this.intentSessionId !== sessionId) return
|
||||
if (session.getSnapshot().intent === null) {
|
||||
this.intentSessionId = undefined
|
||||
this.stopIntentWatch?.()
|
||||
this.stopIntentWatch = undefined
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.notifier.notifyNow()
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active frontend Session Intent.
|
||||
* @returns the active frontend Session, if one remains selected.
|
||||
*/
|
||||
getIntent(): Session | undefined {
|
||||
return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the retained prompt of the active frontend Session.
|
||||
* @param text - exact controlled-input value for the active frontend Session.
|
||||
*/
|
||||
updateIntent(text: string): void {
|
||||
this.getIntent()?.updatePendingPrompt(text)
|
||||
}
|
||||
|
||||
private discardIntent(): void {
|
||||
const session = this.getIntent()
|
||||
this.intentSessionId = undefined
|
||||
this.stopIntentWatch?.()
|
||||
this.stopIntentWatch = undefined
|
||||
session?.abandonIntent()
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
/**
|
||||
* Drop a session instance (scope-prune companion, decision 12: instance
|
||||
* and scope share one lifecycle). The host session log is the durable
|
||||
* truth — a later get() lazily rebuilds and open() backfills history.
|
||||
* @param sessionId - the session to drop.
|
||||
*/
|
||||
drop(sessionId: SessionId): void {
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy build: return the existing instance or construct one (no auto-open —
|
||||
* open is triggered by the container's select callback).
|
||||
@@ -186,31 +135,33 @@ export class SessionManager {
|
||||
if (session === undefined) {
|
||||
session = this.createSession(sessionId)
|
||||
this.sessions.set(sessionId, session)
|
||||
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
|
||||
const summary = this.summaries.find(s => s.sessionId === sessionId)
|
||||
if (summary !== undefined) session.handleRunning(summary.running)
|
||||
// Replay approval/question frames buffered before instantiation (rpcId verbatim, same semantics as the subscribed baseline replay).
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
if (summary !== undefined) {
|
||||
session.handleBlank(summary.blank)
|
||||
session.handleRunning(summary.running)
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private createSession(
|
||||
sessionId: SessionId,
|
||||
intent?: { target: SessionIntentTarget; prompt: string },
|
||||
): Session {
|
||||
private createSession(sessionId: SessionId): Session {
|
||||
return new Session(sessionId, this.api, {
|
||||
...(intent === undefined ? {} : { intent }),
|
||||
onPublished: (published) => {
|
||||
this.sessions.set(published.sessionId, published)
|
||||
this.recordMutation({
|
||||
kind: 'upsert',
|
||||
summary: { sessionId: published.sessionId, updatedAt: Date.now(), running: false },
|
||||
})
|
||||
// The sender's local first-send flip mirrors into the list row so the
|
||||
// session surfaces (lists filter on blank) before any host frame lands.
|
||||
onEngaged: (engaged) => {
|
||||
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -237,8 +188,13 @@ export class SessionManager {
|
||||
this.summaries = summaries
|
||||
this.listState = 'idle'
|
||||
this.listPhase = 'ready'
|
||||
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
|
||||
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
for (const s of this.summaries) {
|
||||
const session = this.sessions.get(s.sessionId)
|
||||
if (session === undefined) continue
|
||||
session.handleBlank(s.blank)
|
||||
session.handleRunning(s.running)
|
||||
}
|
||||
} else {
|
||||
this.listState = 'error'
|
||||
this.listError = result.error
|
||||
@@ -259,7 +215,8 @@ export class SessionManager {
|
||||
|
||||
/**
|
||||
* Contract session.create; on success merge into summaries immediately (no
|
||||
* wait for the next refresh).
|
||||
* wait for the next refresh). A created session is blank by definition
|
||||
* (entity birth precedes the first message).
|
||||
* @param opts - target workspace or working directory, plus an optional caller-owned id.
|
||||
* @returns the create result.
|
||||
*/
|
||||
@@ -267,16 +224,14 @@ export class SessionManager {
|
||||
opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
|
||||
): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
try {
|
||||
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
|
||||
const payload = opts.workspaceId !== undefined
|
||||
? { workspaceId: opts.workspaceId, ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }) }
|
||||
: {
|
||||
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
|
||||
...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
|
||||
}
|
||||
? { workspaceId: opts.workspaceId, ...shared }
|
||||
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
|
||||
const { result } = await this.api.sessions.create(payload)
|
||||
if (result.ok) {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false,
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
|
||||
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
||||
} })
|
||||
} else {
|
||||
@@ -289,6 +244,7 @@ export class SessionManager {
|
||||
sessionId: publishedSessionId,
|
||||
updatedAt: Date.now(),
|
||||
running: false,
|
||||
blank: true,
|
||||
} })
|
||||
}
|
||||
}
|
||||
@@ -363,16 +319,31 @@ export class SessionManager {
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
// New mux-generation baseline: buffered session/queued frames belong to
|
||||
// the previous generation and the host is about to resend the live
|
||||
// snapshot — drop them, or every reconnect appends a duplicate batch
|
||||
// (and enough reconnects push real approval/question frames past the
|
||||
// cap). 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/queued')
|
||||
if (kept.length !== buffered.length) {
|
||||
if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId)
|
||||
else this.pendingBuffers.set(frame.sessionId, kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
const session = this.sessions.get(frame.sessionId)
|
||||
if (session === undefined) {
|
||||
// Approval/question frames never hit history: buffer for replay on instantiation;
|
||||
// everything else drops (not instantiated — history fully backfills on open).
|
||||
// Approval/question/queued frames never hit history: buffer for replay on
|
||||
// instantiation; everything else drops (not instantiated — history fully
|
||||
// backfills on open).
|
||||
switch (frame.type) {
|
||||
case 'approval/requested':
|
||||
case 'approval/resolved':
|
||||
case 'question/requested':
|
||||
case 'question/resolved': {
|
||||
case 'question/resolved':
|
||||
case 'session/queued': {
|
||||
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
|
||||
buffer.push(envelope)
|
||||
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
|
||||
@@ -395,11 +366,11 @@ export class SessionManager {
|
||||
switch (frame.type) {
|
||||
case 'host/session-added': {
|
||||
this.mergeSummary({
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handlePublished()
|
||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
@@ -441,6 +412,7 @@ export class SessionManager {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.title === entry.title && prev.depth === entry.depth
|
||||
) return prev
|
||||
@@ -452,24 +424,13 @@ export class SessionManager {
|
||||
}
|
||||
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
|
||||
if (!sameOrder) this.itemsCache = items
|
||||
const intentSession = this.getIntent()
|
||||
const intentState = intentSession?.getSnapshot()
|
||||
const intent = intentSession !== undefined
|
||||
&& intentState !== undefined && intentState.intent !== null && intentState.pendingPrompt !== null
|
||||
? {
|
||||
sessionId: intentSession.sessionId,
|
||||
...intentState.intent,
|
||||
prompt: intentState.pendingPrompt.text,
|
||||
}
|
||||
: undefined
|
||||
const selected = this.selected
|
||||
const current = selected !== undefined && (
|
||||
intent?.sessionId === selected || items.some(item => item.sessionId === selected)
|
||||
) ? selected : undefined
|
||||
const current = selected !== undefined && items.some(item => item.sessionId === selected)
|
||||
? selected
|
||||
: undefined
|
||||
return {
|
||||
items: this.itemsCache,
|
||||
current,
|
||||
intent,
|
||||
state: this.listState,
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
@@ -485,18 +446,29 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
|
||||
if (existing === undefined) return [mutation.summary, ...summaries]
|
||||
const filled: SessionSummary = {
|
||||
...existing,
|
||||
// Blank only lowers: a stale true (session-added racing the local
|
||||
// first send) never re-hides an already-surfaced session.
|
||||
blank: existing.blank && mutation.summary.blank,
|
||||
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
|
||||
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
|
||||
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
||||
}
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId) return [...summaries]
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
|
||||
&& filled.blank === existing.blank) return [...summaries]
|
||||
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
|
||||
}
|
||||
case 'remove':
|
||||
return summaries.filter(summary => summary.sessionId !== mutation.sessionId)
|
||||
case 'status':
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.running !== mutation.running
|
||||
? { ...summary, running: mutation.running }
|
||||
// running:true doubles as the cross-端 blank flip (a blank session
|
||||
// never runs, so the first running frame proves a message landed).
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId
|
||||
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
|
||||
? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
|
||||
: summary)
|
||||
case 'engaged':
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank
|
||||
? { ...summary, blank: false }
|
||||
: summary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
|
||||
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
|
||||
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
|
||||
//
|
||||
// Freshness and notification are SEPARATE bits: a pull (ensureFresh) between
|
||||
// markDirty and the scheduled flush rebuilds the snapshot but must not
|
||||
// swallow the notification — push subscribers (object-layer watchers) would
|
||||
// otherwise starve whenever any reader pulls first.
|
||||
|
||||
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
|
||||
export class Notifier {
|
||||
private listeners = new Set<() => void>()
|
||||
private dirty = false
|
||||
private notifyPending = false
|
||||
private scheduled = false
|
||||
|
||||
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
|
||||
@@ -28,14 +34,18 @@ export class Notifier {
|
||||
/** State-change entry: mark dirty and schedule the batched flush. */
|
||||
markDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled) return
|
||||
this.scheduled = true
|
||||
queueMicrotask(() => {
|
||||
this.scheduled = false
|
||||
if (!this.dirty) return
|
||||
if (this.listeners.size === 0) return // lazy: no subscribers, keep dirty for the next getSnapshot
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
if (!this.notifyPending) return
|
||||
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
|
||||
this.notifyPending = false
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
for (const listener of this.listeners) listener()
|
||||
})
|
||||
}
|
||||
@@ -46,13 +56,18 @@ export class Notifier {
|
||||
*/
|
||||
notifyNow(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
|
||||
this.notifyPending = false
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
|
||||
/** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). */
|
||||
/**
|
||||
* Pre-getSnapshot check: rebuild synchronously when dirty (read path
|
||||
* before first subscribe / while unobserved). Notification stays pending.
|
||||
*/
|
||||
ensureFresh(): void {
|
||||
if (!this.dirty) return
|
||||
this.dirty = false
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
|
||||
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||
* id), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||
* resolution (pure — resolution has no side effects and is render-safe);
|
||||
@@ -18,15 +19,15 @@ import type { Context, Fiber } from 'cordis'
|
||||
import type {
|
||||
HostDescription, IApiClient, RpcError, SessionId, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type {
|
||||
SessionIntentListSnapshot, SessionListPhase,
|
||||
} from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
import type { SessionIntentTarget } from './conversation.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
@@ -38,6 +39,13 @@ export interface SessionSummary {
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
* store carries every row, while the Workspace browser shows only the
|
||||
* selected blank entry.
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
@@ -50,17 +58,13 @@ export interface SessionListState {
|
||||
ids: SessionId[]
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
current: SessionId | undefined
|
||||
/** Frontend Session Intent projected from its owning Session object. */
|
||||
intent: SessionIntentListSnapshot | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
}
|
||||
|
||||
/** Structured session-create failure preserving partial publication identity. */
|
||||
/** Structured session-create failure. */
|
||||
export class SessionCreateError extends Error {
|
||||
override readonly name = 'SessionCreateError'
|
||||
/** Definitely published by Host before Workspace attachment failed. */
|
||||
readonly publishedSessionId: SessionId | undefined
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
@@ -71,9 +75,6 @@ export class SessionCreateError extends Error {
|
||||
readonly requestedSessionId: SessionId | undefined,
|
||||
) {
|
||||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
this.publishedSessionId = rpcError.code === 'workspace-attach-failed'
|
||||
? rpcError.details.sessionId
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,20 +85,10 @@ export interface SessionBinding {
|
||||
readonly ctx: Context
|
||||
}
|
||||
|
||||
/** Scope tag key (client counterpart of the host dsh-scope pattern). */
|
||||
const kScope = Symbol('dsh.client.scope')
|
||||
|
||||
/**
|
||||
* Read the session scope tag off a context.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
export function scopeOf(ctx: Context): SessionId | undefined {
|
||||
return (ctx as Context & { [kScope]?: SessionId })[kScope]
|
||||
}
|
||||
|
||||
/** Shared no-op plugin backing each session scope fiber. */
|
||||
function sessionScope(): void {}
|
||||
// Scope primitives live in ../agents/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'
|
||||
|
||||
/**
|
||||
* Workspace display title of a session cwd: the path's last non-empty
|
||||
@@ -130,8 +121,30 @@ interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
/** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
|
||||
cell: SessionCell
|
||||
/** 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 SessionsService.provide}). */
|
||||
export interface SessionProvideContribution {
|
||||
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
|
||||
hooks?: Record<string, HostObservable<unknown>>
|
||||
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, ancestry. */
|
||||
@@ -152,6 +165,10 @@ export class SessionsService {
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Registered per-session standard-props providers, in registration order. */
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
@@ -173,7 +190,7 @@ export class SessionsService {
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'pending',
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
@@ -185,6 +202,13 @@ export class SessionsService {
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
// 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.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
@@ -204,6 +228,87 @@ export class SessionsService {
|
||||
return this.description
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider: every session-scope slot
|
||||
* component receives the contributed members as standard props (`hooks`
|
||||
* sources become `use<Name>` 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 {
|
||||
this.providers.push(descriptor)
|
||||
// Scopes may already exist (boot order: the list lands and resolves
|
||||
// scopes before later plugins register) — their bundles must include
|
||||
// every provider by first render, so re-materialize on roster change.
|
||||
this.rematerializeProvideBundles()
|
||||
return () => {
|
||||
const at = this.providers.indexOf(descriptor)
|
||||
if (at >= 0) this.providers.splice(at, 1)
|
||||
this.rematerializeProvideBundles()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
|
||||
private rematerializeProvideBundles(): void {
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
|
||||
const hooks: Record<string, undefined> = {}
|
||||
const props: Record<string, undefined> = {}
|
||||
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 }
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
|
||||
const hooks: Record<string, HostObservable<unknown>> = {}
|
||||
const props: Record<string, unknown> = {}
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere.
|
||||
@@ -224,32 +329,6 @@ export class SessionsService {
|
||||
this.manager.clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or retarget the sole client-local Session intent.
|
||||
* @param target - resolved real or frontend-only Workspace target.
|
||||
* @param prompt - optional prompt retained across retargeting.
|
||||
* @returns the frontend Session object that owns the Intent.
|
||||
*/
|
||||
startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
||||
return this.manager.startIntent(target, prompt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active frontend Session Intent.
|
||||
* @returns the active frontend Session object, if one exists.
|
||||
*/
|
||||
intent(): Session | undefined {
|
||||
return this.manager.getIntent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the retained prompt of the active frontend Session.
|
||||
* @param text - exact controlled-input value for the current Session Intent.
|
||||
*/
|
||||
updateIntent(text: string): void {
|
||||
this.manager.updateIntent(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started baseline pull.
|
||||
@@ -280,21 +359,26 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* 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
|
||||
* 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.
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @throws {SessionCreateError} with the requested id and, after an attach
|
||||
* failure, the definitely published id.
|
||||
* @throws {SessionCreateError} with the requested id.
|
||||
*/
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session-scoped context view (use-and-discard).
|
||||
* @param id - session id.
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined {
|
||||
@@ -302,7 +386,7 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session scope tag off a context. Service-method seam: fetch
|
||||
* Read the Agent scope tag off a context. Service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
@@ -310,7 +394,22 @@ export class SessionsService {
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeOf(ctx)
|
||||
return scopeTagOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the business Session behind an Agent-scoped context — the one
|
||||
* 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 seam as
|
||||
* {@link SessionsService.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): Session | undefined {
|
||||
const id = scopeTagOf(ctx)
|
||||
if (id === undefined) return undefined
|
||||
return this.scopes.get(id)?.binding.session
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,16 +423,26 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer session cell (SessionProvider's feed through
|
||||
* the renderer host; ctx never enters the render layer). Pure resolution —
|
||||
* render-safe: SessionProvider calls this during render, so no staging, no
|
||||
* window side effects (StrictMode double-invokes and concurrent discarded
|
||||
* passes must stay free).
|
||||
* Resolve the render-layer standard-props bundle (SessionProvider's feed
|
||||
* through the renderer host; ctx never enters the render layer). Pure
|
||||
* resolution — render-safe: SessionProvider calls this during render, so no
|
||||
* staging, no window side effects (StrictMode double-invokes and concurrent
|
||||
* discarded passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns cell, or undefined for a session neither listed nor already scoped.
|
||||
* @returns the provide info, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
cell(id: string): SessionCell | undefined {
|
||||
return this.resolve(id as SessionId)?.cell
|
||||
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.
|
||||
* @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.maybeInfo
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -379,29 +488,42 @@ export class SessionsService {
|
||||
return chain
|
||||
}
|
||||
|
||||
/** Lazily mint the scope + binding for a listed (or already-scoped) session. */
|
||||
/**
|
||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||
* prune share one predicate (decision 12): listed on the host — a scope is
|
||||
* born when its session enters the client's view (list mirror row from the
|
||||
* baseline pull, a create() echo, or the session-added frame) and dies with
|
||||
* the prune when the row leaves.
|
||||
*/
|
||||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||||
const existing = this.scopes.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
// Frozen scopes outlive the list; new scopes are only minted for listed sessions.
|
||||
if (this.list.getSnapshot().byId[id] === undefined) return undefined
|
||||
const fiber = this.rootCtx.plugin(sessionScope)
|
||||
const ctx = fiber.ctx.extend({ [kScope]: id })
|
||||
if (!this.eligible(id)) return undefined
|
||||
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 record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding: { sessionId: id, session, ctx },
|
||||
// Session is the observable; React binds a selector hook at its own seam.
|
||||
cell: { sessionId: id, session },
|
||||
binding,
|
||||
// Sources are bare observables; React binds selector hooks at its own seam.
|
||||
provideInfo: this.materializeProvideInfo(binding),
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
|
||||
private eligible(id: SessionId): boolean {
|
||||
return this.list.getSnapshot().byId[id] !== undefined
|
||||
}
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const { items, current, intent, phase } = this.manager.getListSnapshot()
|
||||
const { items, current, phase } = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
@@ -410,6 +532,7 @@ export class SessionsService {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
@@ -417,19 +540,22 @@ export class SessionsService {
|
||||
}
|
||||
}
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
if (intent?.sessionId === current) {
|
||||
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
||||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||||
if (current === undefined) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (current !== undefined && byId[current] !== undefined && persisted !== current) {
|
||||
} else if (byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
}
|
||||
this.list.set({ ids, byId, current, intent, phase })
|
||||
this.list.set({ ids, byId, current, phase })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
/** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
|
||||
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
||||
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
||||
void byId
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (byId[id] !== undefined) continue
|
||||
if (this.eligible(id)) continue
|
||||
if (id === this.watched) {
|
||||
this.deferredRemovals.add(id)
|
||||
continue
|
||||
@@ -440,12 +566,22 @@ export class SessionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
|
||||
/**
|
||||
* One teardown for the whole per-session axis (decision 12): 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
|
||||
* durable truth, a reopen lazily rebuilds and backfills via open().
|
||||
*/
|
||||
private dropScope(id: SessionId, record: ScopeRecord): void {
|
||||
void record.fiber.dispose()
|
||||
// Release the Session's dispatch point with the scope it belongs to (a
|
||||
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
||||
record.binding.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)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
||||
@@ -455,8 +591,8 @@ export class SessionsService {
|
||||
* stage move sweeps first, so the set cannot contain the id the stage just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Still absent from the list? (A re-added id cancels the deferred teardown.)
|
||||
if (this.list.getSnapshot().byId[id] !== undefined) {
|
||||
// Eligible again? (A re-added id cancels the deferred teardown.)
|
||||
if (this.eligible(id)) {
|
||||
this.deferredRemovals.delete(id)
|
||||
continue
|
||||
}
|
||||
|
||||
590
packages/client/runtime/src/client/sessions/service.ts.orig
Normal file
590
packages/client/runtime/src/client/sessions/service.ts.orig
Normal file
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||
* id), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||
* resolution (pure — resolution has no side effects and is render-safe);
|
||||
* the event window and deferred teardown key off the STAGED session, which
|
||||
* follows `list.current` exactly. Staging is the open signal: the window
|
||||
* opens ⟺ the session is on stage (today the stage is `current`; the staged
|
||||
* state can widen to a multi-pane list later). A session leaving the list
|
||||
* tears its scope down immediately unless it is the staged one, whose scope
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
id: SessionId
|
||||
/** Latest durable log-backed title, absent until the host projects one. */
|
||||
title?: string
|
||||
/** Human-facing label: durable title, project basename, then session id. */
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). List surfaces hide blank
|
||||
* sessions; New Session reuses a blank one targeting the same workspace.
|
||||
* Filtering stays with the consumer — the store carries every row.
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export interface SessionListState {
|
||||
ids: SessionId[]
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
current: SessionId | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
}
|
||||
|
||||
/** Structured session-create failure. */
|
||||
export class SessionCreateError extends Error {
|
||||
override readonly name = 'SessionCreateError'
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: RpcError,
|
||||
readonly requestedSessionId: SessionId | undefined,
|
||||
) {
|
||||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: Session
|
||||
readonly ctx: Context
|
||||
}
|
||||
|
||||
// Scope primitives live in ../agents/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'
|
||||
|
||||
/**
|
||||
* Workspace display title of a session cwd: the path's last non-empty
|
||||
* segment (both separators accepted; trailing separators ignored), or ''
|
||||
* for separator-only paths — callers own their fallback (session id, raw
|
||||
* cwd, default-directory copy). The repo-wide single basename derivation —
|
||||
* every surface naming a workspace (picker rows, toggle labels, list titles)
|
||||
* calls this instead of re-splitting paths.
|
||||
* @param cwd - workspace directory path.
|
||||
* @returns basename title, or '' when no non-empty segment exists.
|
||||
*/
|
||||
export function workspaceTitleOf(cwd: string): string {
|
||||
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Display title projection: durable title, project directory basename, then
|
||||
* the raw id.
|
||||
*/
|
||||
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
|
||||
if (title !== undefined) return title
|
||||
if (cwd !== undefined && cwd !== '') {
|
||||
const base = workspaceTitleOf(cwd)
|
||||
if (base !== '') return base
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
/** 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 SessionsService.provide}). */
|
||||
export interface SessionProvideContribution {
|
||||
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
|
||||
hooks?: Record<string, HostObservable<unknown>>
|
||||
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, ancestry. */
|
||||
export class SessionsService {
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Registered per-session standard-props providers, in registration order. */
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
* `current` without moving the stage, so reconnect re-pulls and removals
|
||||
* keep the staged scope's frozen view alive until the stage moves on).
|
||||
*/
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
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.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
// 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.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
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<Name>` 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 {
|
||||
this.providers.push(descriptor)
|
||||
// Scopes may already exist (boot order: the list lands and resolves
|
||||
// scopes before later plugins register) — their bundles must include
|
||||
// every provider by first render, so re-materialize on roster change.
|
||||
this.rematerializeProvideBundles()
|
||||
return () => {
|
||||
const at = this.providers.indexOf(descriptor)
|
||||
if (at >= 0) this.providers.splice(at, 1)
|
||||
this.rematerializeProvideBundles()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
|
||||
private rematerializeProvideBundles(): void {
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
|
||||
const hooks: Record<string, undefined> = {}
|
||||
const props: Record<string, undefined> = {}
|
||||
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 }
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
|
||||
const hooks: Record<string, HostObservable<unknown>> = {}
|
||||
const props: Record<string, unknown> = {}
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere.
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
this.manager.select(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
* Wipes the persisted selection too — a reload stays on empty until the
|
||||
* user opens or starts a session. The staged scope keeps its frozen view
|
||||
* per the masked-gap contract until the next open() moves the stage.
|
||||
*/
|
||||
clear(): void {
|
||||
this.manager.clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started baseline pull.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mux stream envelope into the Session object layer.
|
||||
* @param envelope - validated mux stream envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
|
||||
this.manager.handleMuxEnvelope(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Host stream envelope into the Session object layer.
|
||||
* @param envelope - validated Host stream envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
|
||||
this.manager.handleHostEnvelope(envelope)
|
||||
}
|
||||
|
||||
/** Rebuild the Session baseline and every opened window after connection. */
|
||||
handleConnected(): void {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @throws {SessionCreateError} with the requested id.
|
||||
*/
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Agent scope tag off a context. Service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeTagOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the business Session behind an Agent-scoped context — the one
|
||||
* 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 seam as
|
||||
* {@link SessionsService.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): Session | undefined {
|
||||
const id = scopeTagOf(ctx)
|
||||
if (id === undefined) return undefined
|
||||
return this.scopes.get(id)?.binding.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable session binding (scope-addressed assembly feed). Pure
|
||||
* resolution — no staging, no window side effects.
|
||||
* @param id - session id.
|
||||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
binding(id: SessionId): SessionBinding | undefined {
|
||||
return this.resolve(id)?.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer standard-props bundle (SessionProvider's feed
|
||||
* through the renderer host; ctx never enters the render layer). Pure
|
||||
* resolution — render-safe: SessionProvider calls this during render, so no
|
||||
* staging, no window side effects (StrictMode double-invokes and concurrent
|
||||
* discarded passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns the provide info, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
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.
|
||||
* @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.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.
|
||||
* Staging IS the open signal — the window opens ⟺ the session is on stage
|
||||
* — and open() is idempotent (an in-flight or completed open no-ops; a
|
||||
* failed one retries the next time current is touched).
|
||||
*/
|
||||
private followCurrent(): void {
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const current = snapshot.current
|
||||
// A masked gap (current blanked while the selection's session is
|
||||
// transiently absent) holds the stage: tearing down on the gap would
|
||||
// destroy exactly the frozen scope the mask exists to preserve.
|
||||
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
|
||||
this.watched = current
|
||||
this.sweepDeferred()
|
||||
const record = this.resolve(current)
|
||||
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
|
||||
* validates and the projection masks absent selections), so resolve
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.binding.session.open()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb feed: walk parentId links inside the list store.
|
||||
* @param id - session id.
|
||||
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
|
||||
*/
|
||||
ancestry(id: SessionId): SessionSummary[] {
|
||||
const { byId } = this.list.getSnapshot()
|
||||
const chain: SessionSummary[] = []
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
const summary: SessionSummary | undefined = byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||
* prune share one predicate (decision 12): listed on the host — a scope is
|
||||
* born when its session enters the client's view (list mirror row from the
|
||||
* baseline pull, a create() echo, or the session-added frame) and dies with
|
||||
* the prune when the row leaves.
|
||||
*/
|
||||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||||
const existing = this.scopes.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
if (!this.eligible(id)) return undefined
|
||||
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 record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding,
|
||||
// Sources are bare observables; React binds selector hooks at its own seam.
|
||||
provideInfo: this.materializeProvideInfo(binding),
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
|
||||
private eligible(id: SessionId): boolean {
|
||||
return this.list.getSnapshot().byId[id] !== undefined
|
||||
}
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const { items, current, phase } = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
ids.push(entry.sessionId)
|
||||
byId[entry.sessionId] = {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
||||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||||
if (current === undefined) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
||||
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
||||
void byId
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (this.eligible(id)) continue
|
||||
if (id === this.watched) {
|
||||
this.deferredRemovals.add(id)
|
||||
continue
|
||||
}
|
||||
this.scopes.delete(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One teardown for the whole per-session axis (decision 12): 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
|
||||
* durable truth, a reopen lazily rebuilds and backfills via open().
|
||||
*/
|
||||
private dropScope(id: SessionId, record: ScopeRecord): void {
|
||||
void record.fiber.dispose()
|
||||
// Release the Session's dispatch point with the scope it belongs to (a
|
||||
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
||||
record.binding.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)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
/* v8 ignore next -- defensive: only the staged id ever defers, and every
|
||||
* stage move sweeps first, so the set cannot contain the id the stage just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Eligible again? (A re-added id cancels the deferred teardown.)
|
||||
if (this.eligible(id)) {
|
||||
this.deferredRemovals.delete(id)
|
||||
continue
|
||||
}
|
||||
const record = this.scopes.get(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
||||
* together, so a deferred id always still owns its record; kept so a
|
||||
* future teardown path cannot double-dispose. */
|
||||
if (record !== undefined) {
|
||||
this.scopes.delete(id)
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, PromptContentPart, RpcError, RpcId,
|
||||
RpcResult, SessionId, ToolEventView, WorkspaceId,
|
||||
RpcResult, SessionId, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/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 { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt,
|
||||
PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
|
||||
PromptError, QueuedMessage, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
@@ -23,10 +25,37 @@ import { PartialAccumulator } from './partial.ts'
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** Optional frontend Intent and publication observer for a Session object. */
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
intent?: { target: SessionIntentTarget; prompt: string }
|
||||
onPublished?(session: Session): void
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||
const QUEUE_PREVIEW_CHARS = 200
|
||||
|
||||
/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */
|
||||
interface QueuedEntry {
|
||||
row: QueuedMessage
|
||||
steering: boolean
|
||||
/** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */
|
||||
sourceJson: string
|
||||
}
|
||||
|
||||
/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */
|
||||
function queuePreviewOf(content: readonly ContentBlock[]): string {
|
||||
const flat = content
|
||||
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
|
||||
.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const chars = Array.from(flat)
|
||||
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,8 +93,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
|
||||
private pendingRev = 0
|
||||
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
|
||||
/** Inbox mirror (session/queued frames + mux-open baseline). Queue frames never hit history,
|
||||
* so this is stream-only state: reconnect clears it and the fresh baseline re-populates. */
|
||||
private queued: QueuedEntry[] = []
|
||||
private queueRev = 0
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
private running = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
@@ -73,14 +112,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* engaging edge of the phase machine (see ComposerPhase).
|
||||
*/
|
||||
private promptAttempted = false
|
||||
/** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */
|
||||
private blankBit = false
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private intent: SessionIntentSnapshot | null
|
||||
private pendingPrompt: PendingPrompt | null
|
||||
/** Browser-prepared image parts retained with a Session Intent across attach/send retries. */
|
||||
private pendingImages: PromptContentPart[] = []
|
||||
private intentGeneration = 0
|
||||
private published: boolean
|
||||
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 }[] = []
|
||||
@@ -93,27 +128,46 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
/**
|
||||
* Agent-scoped cordis context, bound once by SessionsService 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 - stable identity shared by the frontend Intent and Host entity.
|
||||
* @param sessionId - Host session identity (client sessions are always Host-born).
|
||||
* @param api - shared wire client.
|
||||
* @param options - optional frontend-only initial state and publication observer.
|
||||
* @param options - optional manager-owned state observers.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.intent = options.intent === undefined
|
||||
? null
|
||||
: { target: options.intent.target, phase: 'ready' }
|
||||
this.pendingPrompt = options.intent === undefined
|
||||
? null
|
||||
: { text: options.intent.prompt, phase: 'editing', retry: 'send' }
|
||||
this.published = options.intent === undefined
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the Agent-scoped context minted by SessionsService (single write;
|
||||
* a second bind is a wiring error and throws). Direction stays one-way at
|
||||
* the seam: 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 ----
|
||||
|
||||
/**
|
||||
@@ -139,6 +193,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (!result.ok) {
|
||||
this.promptError = { op: 'send', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt has logged
|
||||
// its user/message on the host (events.length > 0 is fact, not
|
||||
// optimism), 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
|
||||
}
|
||||
@@ -165,79 +231,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this Session's retained prompt while it remains editable.
|
||||
* @param text - exact controlled value of this Session's retained prompt.
|
||||
*/
|
||||
updatePendingPrompt(text: string): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending') return
|
||||
this.pendingPrompt = { ...pending, text }
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace images retained by this editable Session Intent.
|
||||
* @param images - canonical prompt image parts prepared by the browser UI.
|
||||
*/
|
||||
updatePendingImages(images: readonly PromptContentPart[]): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending') return
|
||||
this.pendingImages = images.map(image => ({ ...image }))
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the retained Session Intent has text or images to submit.
|
||||
* @returns true when connect/send may proceed.
|
||||
*/
|
||||
hasPendingContent(): boolean {
|
||||
return (this.pendingPrompt?.text.trim() ?? '') !== '' || this.pendingImages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect this frontend Session to a real Workspace and flush its retained prompt.
|
||||
* @param workspaceId - real Workspace target.
|
||||
*/
|
||||
connect(workspaceId: WorkspaceId): void {
|
||||
const intent = this.intent
|
||||
const pending = this.pendingPrompt
|
||||
if (intent === null || intent.phase === 'connecting' || pending === null || !this.hasPendingContent()) return
|
||||
const connecting: SessionIntentSnapshot = {
|
||||
target: { kind: 'workspace', workspaceId },
|
||||
phase: 'connecting',
|
||||
}
|
||||
const queued: PendingPrompt = {
|
||||
...pending,
|
||||
phase: 'sending',
|
||||
retry: 'connect',
|
||||
workspaceId,
|
||||
}
|
||||
delete queued.error
|
||||
this.intent = connecting
|
||||
this.pendingPrompt = queued
|
||||
this.notifier.notifyNow()
|
||||
void this.flushPendingPrompt()
|
||||
}
|
||||
|
||||
/** Stop a superseded frontend Intent from automatically sending after publication. */
|
||||
abandonIntent(): void {
|
||||
if (this.intent === null) return
|
||||
this.intentGeneration += 1
|
||||
}
|
||||
|
||||
/** Retry this Session's retained prompt from its failed prerequisite. */
|
||||
retryPendingPrompt(): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending' || !this.hasPendingContent()) return
|
||||
const sending: PendingPrompt = { ...pending, phase: 'sending' }
|
||||
delete sending.error
|
||||
this.pendingPrompt = sending
|
||||
this.promptError = null
|
||||
this.notifier.markDirty()
|
||||
void this.flushPendingPrompt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
|
||||
* @returns the cancel result.
|
||||
@@ -310,6 +303,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* in-flight open first — its history request rode the dead connection and must not settle
|
||||
* the fresh generation into 'error' (audit S4). */
|
||||
async resync(): Promise<void> {
|
||||
// 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
|
||||
@@ -358,12 +356,35 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
|
||||
switch (frame.type) {
|
||||
case 'session/event': {
|
||||
this.retireQueued(frame.event)
|
||||
this.acceptLiveEvent(frame.event, frame.view)
|
||||
return
|
||||
}
|
||||
case 'session/queued': {
|
||||
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
|
||||
// provisional-echo reconciliation key); otherwise the frame envelope id.
|
||||
const key = 'rpcId' in frame.source ? String(frame.source.rpcId) : `f:${rpcId}`
|
||||
this.queued.push({
|
||||
row: { key, preview: queuePreviewOf(frame.content) },
|
||||
steering: frame.steering,
|
||||
sourceJson: JSON.stringify(frame.source),
|
||||
})
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'session/subscribed': {
|
||||
this.subscribedLastSeq = frame.lastSeq
|
||||
return // pure baseline bookkeeping, no visible change
|
||||
// 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.queued.length > 0) {
|
||||
this.queued = []
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'approval/requested': {
|
||||
const { type: _type, sessionId: _sid, ...payload } = frame
|
||||
@@ -400,14 +421,38 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* @param running - the new running state.
|
||||
*/
|
||||
handleRunning(running: boolean): void {
|
||||
// Leave-running sweep (host queuedMirror precedent): discard paths (cancel,
|
||||
// terminal steering drop) have no per-entry frame, so ANY not-running signal
|
||||
// with a nonempty mirror clears it — checked before the equality return so a
|
||||
// stale replay on an already-idle session still sweeps.
|
||||
if (!running && this.queued.length > 0) {
|
||||
this.queued = []
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
// Turn-start conversion: a blank session never runs, so the first
|
||||
// running:true proves another端's first message landed (设计稿 2.2).
|
||||
if (running && this.blankBit) {
|
||||
this.blankBit = false
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
if (this.running === running) return
|
||||
this.running = running
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Mark that Host publication is known without resolving an uncertain local create response. */
|
||||
handlePublished(): void {
|
||||
this.markPublished()
|
||||
/**
|
||||
* 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). */
|
||||
@@ -443,119 +488,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.pendingRev++
|
||||
}
|
||||
|
||||
/** Advance the retained prompt through Session attachment and submission. */
|
||||
private async flushPendingPrompt(): Promise<void> {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending?.phase === 'sending') {
|
||||
const ready = pending.retry === 'connect'
|
||||
? await this.attachPendingPrompt(pending)
|
||||
: pending
|
||||
if (ready !== null) await this.sendPendingPrompt(ready)
|
||||
}
|
||||
}
|
||||
|
||||
/** Complete the Host Session prerequisite and return the prompt's send step. */
|
||||
private async attachPendingPrompt(pending: PendingPrompt): Promise<PendingPrompt | null> {
|
||||
const workspaceId = pending.workspaceId
|
||||
if (workspaceId === undefined) throw new Error('a Session attachment requires a Workspace id')
|
||||
const originIntent = this.intent
|
||||
const originGeneration = this.intentGeneration
|
||||
let result: RpcResult<{ sessionId: SessionId }>
|
||||
try {
|
||||
result = (await this.api.sessions.create({ sessionId: this.sessionId, workspaceId })).result
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
let ready: PendingPrompt | null = null
|
||||
if (result.ok) {
|
||||
ready = this.completePendingAttachment(pending, originIntent, originGeneration)
|
||||
} else {
|
||||
this.failPendingAttachment(pending, originIntent, originGeneration, result.error)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return ready
|
||||
}
|
||||
|
||||
/** Move a published Session to the send step unless its page intent was superseded. */
|
||||
private completePendingAttachment(
|
||||
pending: PendingPrompt,
|
||||
originIntent: SessionIntentSnapshot | null,
|
||||
originGeneration: number,
|
||||
): PendingPrompt | null {
|
||||
this.markPublished()
|
||||
this.intent = null
|
||||
this.promptAttempted = true
|
||||
const superseded = originIntent !== null && originGeneration !== this.intentGeneration
|
||||
const next: PendingPrompt = {
|
||||
...pending,
|
||||
phase: superseded ? 'failed' : 'sending',
|
||||
retry: 'send',
|
||||
...(superseded ? { error: 'Message was not sent because you navigated away.' } : {}),
|
||||
}
|
||||
if (!superseded) delete next.error
|
||||
this.pendingPrompt = next
|
||||
return superseded ? null : next
|
||||
}
|
||||
|
||||
/** Retain the prompt at the failed attachment step that owns the retry. */
|
||||
private failPendingAttachment(
|
||||
pending: PendingPrompt,
|
||||
originIntent: SessionIntentSnapshot | null,
|
||||
originGeneration: number,
|
||||
error: RpcError,
|
||||
): void {
|
||||
const partiallyPublished = error.code === 'workspace-attach-failed'
|
||||
if (partiallyPublished) {
|
||||
this.markPublished()
|
||||
this.intent = null
|
||||
this.promptAttempted = true
|
||||
}
|
||||
const activeIntent = !partiallyPublished
|
||||
&& originIntent !== null
|
||||
&& originGeneration === this.intentGeneration
|
||||
&& this.intent === originIntent
|
||||
if (activeIntent) {
|
||||
this.intent = {
|
||||
target: originIntent.target,
|
||||
phase: 'ready',
|
||||
error: { step: 'session', message: rpcErrorMessage(error) },
|
||||
}
|
||||
this.pendingPrompt = { ...pending, phase: 'editing' }
|
||||
}
|
||||
if (!activeIntent && (partiallyPublished || originIntent === null) && this.pendingPrompt === pending) {
|
||||
this.pendingPrompt = { ...pending, phase: 'failed', error: rpcErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit the retained prompt and keep it only when Host rejects the send. */
|
||||
private async sendPendingPrompt(pending: PendingPrompt): Promise<void> {
|
||||
const text = pending.text.trim()
|
||||
const result = await this.prompt([
|
||||
...this.pendingImages,
|
||||
...(text === '' ? [] : [{ type: 'text' as const, text }]),
|
||||
], 'queue')
|
||||
if (this.pendingPrompt === pending) {
|
||||
if (result.ok) {
|
||||
this.pendingImages = []
|
||||
this.pendingPrompt = null
|
||||
} else {
|
||||
this.pendingPrompt = {
|
||||
...pending,
|
||||
retry: 'send',
|
||||
phase: 'failed',
|
||||
error: rpcErrorMessage(result.error),
|
||||
}
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private markPublished(): void {
|
||||
if (this.published) return
|
||||
this.published = true
|
||||
this.options.onPublished?.(this)
|
||||
}
|
||||
|
||||
/** @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<void> {
|
||||
@@ -658,9 +590,89 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered
|
||||
* turn/start claims the oldest non-steering entry; a steering/message drains the oldest
|
||||
* steering entry with the same source (loop-authored steering matches nothing and drops none). */
|
||||
private retireQueued(event: SessionEvent): void {
|
||||
if (this.queued.length === 0) return
|
||||
let index = -1
|
||||
if (event.type === 'turn/start') {
|
||||
if (event.data.trigger.kind !== 'message') return
|
||||
index = this.queued.findIndex(entry => !entry.steering)
|
||||
} else if (event.type === 'steering/message') {
|
||||
const source = JSON.stringify(event.data.source)
|
||||
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (index < 0) return
|
||||
this.queued.splice(index, 1)
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Per-event side effects (right column of the §A.9 dispatch table):
|
||||
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
|
||||
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
|
||||
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
|
||||
// the host-side dsh-tools plugin whose types cannot enter the client
|
||||
// program (its host Context merges collide with the client's), so this
|
||||
// wire consumer narrows them structurally — the same posture as every
|
||||
// other cross-wire event payload.
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
// A started sub-dispatch enters the index as a RunningToolCall — the
|
||||
// exact shape a native in-flight call renders from — under its parent
|
||||
// run_code callId; it never joins the surface flow.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const running: CodeSubCall = {
|
||||
callId: data.subCallId, name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0, step: 0, time: event.time, callView: null,
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
this.codeDispatches.set(data.parentCallId, [...siblings, running])
|
||||
this.dispatchesRev++
|
||||
return
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
// Settlement replaces the running entry in place (same array position,
|
||||
// so parallel sub-calls keep their start order) with the
|
||||
// ToolResultNode form; a settle with no observed start (history window
|
||||
// cut mid-pair, or a pre-start-event log) appends directly.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
// Duration source: the paired start's time when observed; null =
|
||||
// unknown (settle-only window), matching the native tool-result
|
||||
// contract so views never present a fabricated zero duration.
|
||||
callTime: started === undefined ? null : started.time,
|
||||
content: data.content, isError: data.isError,
|
||||
callView: null, resultView: null,
|
||||
}
|
||||
this.codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
|
||||
)
|
||||
this.dispatchesRev++
|
||||
return
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
@@ -740,6 +752,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.callsRev++
|
||||
this.frozenNodes = []
|
||||
this.frozenRev++
|
||||
this.codeDispatches = new Map()
|
||||
this.dispatchesRev++
|
||||
for (let i = 0; i < this.events.length; i++) {
|
||||
const event = this.events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
@@ -772,6 +786,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
|
||||
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
|
||||
}
|
||||
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
|
||||
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
|
||||
}
|
||||
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
|
||||
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
|
||||
}
|
||||
const partial = this.partial?.toPartial() ?? null
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
@@ -780,6 +800,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
pending: this.pendingCache.value,
|
||||
codeDispatches: this.dispatchesCache.value,
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
composerPhase: derivePhase(
|
||||
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
@@ -791,17 +813,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
hasMore: this.hasMore,
|
||||
loadingOlder: this.loadingOlder,
|
||||
promptError: this.promptError,
|
||||
intent: this.intent,
|
||||
pendingPrompt: this.pendingPrompt,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rpcErrorMessage(error: RpcError): string {
|
||||
return `${error.code}: ${error.message}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The composerPhase judgment — the single site that knows the predicate
|
||||
* (consumers switch on the result, never re-derive). Monotone per session
|
||||
|
||||
@@ -235,7 +235,7 @@ export class SlotsService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build once after both object-layer services mount; session cells still resolve lazily. */
|
||||
/** Build once after both object-layer services mount; per-session provide bundles still resolve lazily. */
|
||||
private hostFace(): SlotRendererHost {
|
||||
if (this._host !== undefined) return this._host
|
||||
const sessions = this.ctx.get('sessions')
|
||||
@@ -264,7 +264,8 @@ export class SlotsService extends Service {
|
||||
sessions: {
|
||||
list: sessions.list,
|
||||
current,
|
||||
cell: id => sessions.cell(id),
|
||||
provideInfo: id => sessions.provideInfo(id),
|
||||
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
|
||||
},
|
||||
workspaces: { list: workspaces.list },
|
||||
}
|
||||
@@ -275,13 +276,13 @@ export class SlotsService extends Service {
|
||||
private resolveStore(handle: EngineStoreHandle, sessionId: string | 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 === 'session' ? sessionId : ROOT_INSTANCE_KEY
|
||||
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
|
||||
const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : sessionId
|
||||
if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`)
|
||||
let instance = record.instances.get(key)
|
||||
if (instance === undefined) {
|
||||
// Session instances get the scope key (the engine suffixes the persist
|
||||
// key per session); root instances stay keyless.
|
||||
instance = record.scope === 'session' ? handle.create(key) : handle.create()
|
||||
instance = record.scope === 'root' ? handle.create() : handle.create(key)
|
||||
record.instances.set(key, instance)
|
||||
}
|
||||
return instance
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
/** Workspace baseline, incremental-frame, and unary-action owner. */
|
||||
|
||||
import type {
|
||||
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView,
|
||||
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
import {
|
||||
Workspace, type WorkspaceCreateInput, type WorkspaceIntentSnapshot,
|
||||
} from './workspace.ts'
|
||||
|
||||
export type { WorkspaceIntentSnapshot } from './workspace.ts'
|
||||
import { Workspace, type WorkspaceCreateInput } from './workspace.ts'
|
||||
|
||||
/** Monotone workspace-list arrival lifecycle. */
|
||||
export type WorkspaceListPhase = 'pending' | 'ready'
|
||||
@@ -18,8 +14,6 @@ export type WorkspaceListPhase = 'pending' | 'ready'
|
||||
/** Immutable workspace-list snapshot. */
|
||||
export interface WorkspaceListSnapshot {
|
||||
items: readonly WorkspaceView[]
|
||||
/** The sole page-local Workspace intent; never persisted or sent over the Host stream. */
|
||||
intent: WorkspaceIntentSnapshot | undefined
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
phase: WorkspaceListPhase
|
||||
error: RpcError | null
|
||||
@@ -28,7 +22,6 @@ export interface WorkspaceListSnapshot {
|
||||
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
|
||||
export class WorkspaceManager {
|
||||
private items: Workspace[] = []
|
||||
private intent: Workspace | undefined
|
||||
private itemViewsSource: readonly Workspace[] | null = null
|
||||
private itemViewsCache: readonly WorkspaceView[] = []
|
||||
private state: WorkspaceListSnapshot['state'] = 'idle'
|
||||
@@ -46,44 +39,6 @@ export class WorkspaceManager {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the current client-local Workspace intent object.
|
||||
* @param name - directory/display name used if the intent is materialized.
|
||||
* @returns the new intent snapshot.
|
||||
*/
|
||||
startIntent(name = 'workspace'): WorkspaceIntentSnapshot {
|
||||
this.intent = new Workspace(this.api, { name })
|
||||
this.notifier.notifyNow()
|
||||
return this.intent.getSnapshot().intent as WorkspaceIntentSnapshot
|
||||
}
|
||||
|
||||
/** Discard the current client-local Workspace intent. */
|
||||
discardIntent(): void {
|
||||
if (this.intent === undefined) return
|
||||
this.intent = undefined
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the current Workspace intent through the ordinary Host create seam.
|
||||
* A superseded intent is never cleared by an older completion.
|
||||
* @returns the Host create result, or undefined when no intent exists.
|
||||
*/
|
||||
async materializeIntent(): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }> | undefined> {
|
||||
const intent = this.intent
|
||||
if (intent?.getSnapshot().intent?.phase !== 'ready') return undefined
|
||||
const completion = intent.materialize()
|
||||
if (completion === undefined) return undefined
|
||||
this.notifier.notifyNow()
|
||||
const result = await completion
|
||||
if (result.ok) {
|
||||
this.upsert(result.value.workspace, intent)
|
||||
if (this.intent === intent) this.intent = undefined
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from workspace.list. The first successful response establishes
|
||||
* Host order; later responses update membership and values without moving
|
||||
@@ -143,6 +98,40 @@ export class WorkspaceManager {
|
||||
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<RpcResult<{ workspace: WorkspaceView }>> {
|
||||
const { result } = await this.api.workspace.rename({ workspaceId, title })
|
||||
if (result.ok) this.upsert(result.value.workspace)
|
||||
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<RpcResult<{ workspace: WorkspaceView }>> {
|
||||
const { result } = await this.api.workspace.insertSessionBefore({
|
||||
workspaceId, sessionId,
|
||||
...beforeSessionId === undefined ? {} : { beforeSessionId },
|
||||
})
|
||||
if (result.ok) this.upsert(result.value.workspace)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-frame entry. Non-workspace frames are ignored so the runtime can
|
||||
* fan one host stream out to both object managers.
|
||||
@@ -178,7 +167,6 @@ export class WorkspaceManager {
|
||||
private buildSnapshot(): WorkspaceListSnapshot {
|
||||
return {
|
||||
items: this.itemViews(),
|
||||
intent: this.intent?.getSnapshot().intent,
|
||||
state: this.state,
|
||||
phase: this.phase,
|
||||
error: this.error,
|
||||
@@ -189,6 +177,11 @@ export class WorkspaceManager {
|
||||
private upsert(view: WorkspaceView, identity?: Workspace): void {
|
||||
this.refreshFrames?.push(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 (identity !== undefined) {
|
||||
this.items = index === -1
|
||||
? [identity, ...this.items]
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, WorkspaceId, WorkspaceView,
|
||||
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionsService } from '../sessions/service.ts'
|
||||
import { WorkspaceManager, type WorkspaceIntentSnapshot, type WorkspaceListPhase } from './manager.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[]
|
||||
/** Sole client-local Workspace projection; its state remains owned by Workspace. */
|
||||
intent: WorkspaceIntentSnapshot | undefined
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
phase: WorkspaceListPhase
|
||||
error: RpcError | null
|
||||
@@ -29,64 +27,128 @@ export class WorkspacesService {
|
||||
readonly list: SnapshotStore<WorkspaceListState>
|
||||
/** Workspace baseline and frame owner. */
|
||||
private readonly manager: WorkspaceManager
|
||||
private initialSessionResolved = false
|
||||
private composingIntent = false
|
||||
/** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */
|
||||
private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
|
||||
/** Guards the runtime-owned one-shot initial-selection subscription. */
|
||||
private initialSelectionStarted = false
|
||||
|
||||
/**
|
||||
* @param ctx - client root context.
|
||||
* @param api - shared wire client.
|
||||
* @param sessions - lower-level Session service used for recency and cross-domain intent orchestration.
|
||||
* @param sessions - lower-level Session service used for recency and blank-session reuse.
|
||||
*/
|
||||
constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) {
|
||||
this.manager = new WorkspaceManager(api)
|
||||
this.list = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'pending', error: null,
|
||||
items: [], state: 'idle', phase: 'pending', error: null,
|
||||
baselinesReady: false, recentWorkspaceId: undefined,
|
||||
})
|
||||
this.manager.subscribe(() => { if (!this.composingIntent) this.project() })
|
||||
this.sessions.list.subscribe(() => { if (!this.composingIntent) this.project() })
|
||||
this.manager.subscribe(() => { this.project() })
|
||||
this.sessions.list.subscribe(() => { this.project() })
|
||||
ctx.reflect.provide('workspaces', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the sole Session intent, resolving the default Workspace here.
|
||||
* @param workspaceId - optional explicit real Workspace target.
|
||||
* @param prompt - optional prompt retained while retargeting.
|
||||
* 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.
|
||||
*/
|
||||
startSession(workspaceId?: WorkspaceId, prompt = ''): void {
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const resolved = workspaceId ?? snapshot.recentWorkspaceId ?? snapshot.items[0]?.workspaceId
|
||||
this.composingIntent = true
|
||||
try {
|
||||
if (resolved === undefined) {
|
||||
this.manager.startIntent()
|
||||
this.sessions.startIntent({ kind: 'workspace-intent' }, prompt)
|
||||
} else {
|
||||
this.manager.discardIntent()
|
||||
this.sessions.startIntent({ kind: 'workspace', workspaceId: resolved }, prompt)
|
||||
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
|
||||
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: blank && same canonical cwd (workspace.path is the host realpath
|
||||
// canon; summary cwd is the session header passthrough of the same canon).
|
||||
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) 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
|
||||
}
|
||||
} finally {
|
||||
this.composingIntent = false
|
||||
this.project()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/** Connect the current frontend Workspace and Session, then flush the Session-owned prompt. */
|
||||
sendSession(): void {
|
||||
const session = this.sessions.intent()
|
||||
const target = session?.getSnapshot().intent?.target
|
||||
if (session === undefined || target === undefined) return
|
||||
if (target.kind === 'workspace') {
|
||||
session.connect(target.workspaceId)
|
||||
/**
|
||||
* The shared New Session action behind the shell entry points (sidebar
|
||||
* button, workspace browser): resolve the target Workspace — explicit wins,
|
||||
* else 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 target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId
|
||||
if (target === undefined) {
|
||||
this.sessions.clear()
|
||||
return
|
||||
}
|
||||
if (!session.hasPendingContent()) return
|
||||
void this.manager.materializeIntent().then((result) => {
|
||||
if (this.sessions.intent() !== session) return
|
||||
if (result?.ok) {
|
||||
session.connect(result.value.workspace.workspaceId)
|
||||
}
|
||||
})
|
||||
void this.connectWorkspace(target).then(
|
||||
(sessionId) => { this.sessions.open(sessionId) },
|
||||
(reason: unknown) => { console.warn('new session failed:', reason) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,6 +162,35 @@ export class WorkspacesService {
|
||||
return result.value.workspace
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<WorkspaceView> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<WorkspaceView> {
|
||||
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.
|
||||
@@ -124,20 +215,15 @@ export class WorkspacesService {
|
||||
private project(): void {
|
||||
const workspace = this.manager.getSnapshot()
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
if (workspace.intent !== undefined && sessions.intent?.target.kind !== 'workspace-intent') {
|
||||
this.manager.discardIntent()
|
||||
return
|
||||
}
|
||||
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
|
||||
this.list.set({
|
||||
...workspace,
|
||||
items: workspace.items,
|
||||
state: workspace.state,
|
||||
phase: workspace.phase,
|
||||
error: workspace.error,
|
||||
baselinesReady,
|
||||
recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined,
|
||||
})
|
||||
if (!this.initialSessionResolved && baselinesReady) {
|
||||
this.initialSessionResolved = true
|
||||
if (sessions.current === undefined && sessions.intent === undefined) this.startSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
@@ -33,6 +35,10 @@ async function mount(): Promise<Bench> {
|
||||
return bench
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
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()
|
||||
@@ -50,7 +56,7 @@ describe('runtime client apply', () => {
|
||||
// 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', sessionId: 's-new' } 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')
|
||||
@@ -77,6 +83,31 @@ describe('runtime client apply', () => {
|
||||
bench.sinks?.onConnected?.()
|
||||
})
|
||||
|
||||
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?.()
|
||||
await flushMicrotasks()
|
||||
|
||||
const sessions = bench.ctx.get('sessions') as SessionsService
|
||||
const workspaces = bench.ctx.get('workspaces') as WorkspacesService
|
||||
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('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'))
|
||||
|
||||
@@ -26,6 +26,16 @@ export const ev = {
|
||||
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
|
||||
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
|
||||
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
|
||||
}),
|
||||
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch',
|
||||
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
|
||||
}),
|
||||
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// 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, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
|
||||
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -95,9 +96,37 @@ export class FakeApiClient implements IApiClient {
|
||||
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
|
||||
|
||||
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
insertSessionBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(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.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connecti
|
||||
import { flattenLineage } from '../src/client/sessions/lineage.ts'
|
||||
|
||||
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
|
||||
sessionId: id as SessionId, updatedAt, running: false,
|
||||
sessionId: id as SessionId, updatedAt, running: false, blank: false,
|
||||
...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}),
|
||||
})
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ import { entries, plainTurn } from './event-script.ts'
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
const S2 = 'fk-m2' as SessionId
|
||||
|
||||
function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; parentSessionId: SessionId }> = {}) {
|
||||
return { sessionId, updatedAt: 100, running: false, ...over }
|
||||
type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }>
|
||||
|
||||
function summary(sessionId: SessionId, over: SummaryOver = {}) {
|
||||
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
|
||||
}
|
||||
|
||||
describe('instances', () => {
|
||||
@@ -81,7 +83,7 @@ describe('list lifecycle', () => {
|
||||
const hydration = manager.refreshList()
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'during-first' as never,
|
||||
payload: { type: 'host/session-added', sessionId: S2 },
|
||||
payload: { type: 'host/session-added', blank: true, sessionId: S2 },
|
||||
})
|
||||
first.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
await hydration
|
||||
@@ -157,7 +159,7 @@ describe('list lifecycle', () => {
|
||||
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', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -196,8 +198,8 @@ describe('host frame routing', () => {
|
||||
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S1 } }) // dup: ignored
|
||||
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
|
||||
expect(manager.getListSnapshot().items).toHaveLength(1)
|
||||
|
||||
const session = manager.get(S1)
|
||||
@@ -273,14 +275,14 @@ describe('remaining branches', () => {
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'published-later' as never,
|
||||
payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' },
|
||||
payload: { type: 'host/session-added', blank: true, sessionId: S1, 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', sessionId: S1, cwd: '/w/one' },
|
||||
payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' },
|
||||
})
|
||||
expect(manager.getListSnapshot().items).toHaveLength(1)
|
||||
})
|
||||
@@ -295,7 +297,7 @@ describe('remaining branches', () => {
|
||||
expect(notified).toBeGreaterThan(0)
|
||||
const seen = notified
|
||||
unsubscribe()
|
||||
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(notified).toBe(seen)
|
||||
})
|
||||
@@ -334,8 +336,8 @@ describe('remaining branches', () => {
|
||||
it('carries parentSessionId from host/session-added into the lineage row', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1 } })
|
||||
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 } })
|
||||
const items = manager.getListSnapshot().items
|
||||
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
|
||||
})
|
||||
|
||||
193
packages/client/runtime/tests/queue-store.spec.ts
Normal file
193
packages/client/runtime/tests/queue-store.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Queue mirror semantics (web input-triggers queue cut 1): session/queued
|
||||
* intake, host-rule retirement (message turn/start claims oldest non-steering;
|
||||
* steering/message drains by source), leave-running sweep, reconnect reset,
|
||||
* pre-instantiation buffering, and snapshot reference stability.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
import { ev } from './event-script.ts'
|
||||
|
||||
const SID = 'fk-q1' as SessionId
|
||||
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
|
||||
const rid = (id: string): RpcId => id as RpcId
|
||||
|
||||
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
|
||||
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
|
||||
return {
|
||||
type: 'session/queued', sessionId: SID, content: text(body),
|
||||
source: { kind: 'user', rpcId: rid(rpcId) } as never, steering,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(): Session {
|
||||
return new Session(SID, new FakeApiClient())
|
||||
}
|
||||
|
||||
describe('queue intake', () => {
|
||||
it('lands a queued frame as a row keyed by the source rpcId with a flat preview', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1'))
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }])
|
||||
})
|
||||
|
||||
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-2'), {
|
||||
type: 'session/queued', sessionId: SID,
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
source: { kind: 'plugin', plugin: 'loop' }, steering: false,
|
||||
})
|
||||
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
|
||||
})
|
||||
|
||||
it('caps the preview at 200 code points with an ellipsis', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap'))
|
||||
const preview = session.getSnapshot().queue[0]?.preview ?? ''
|
||||
expect(Array.from(preview)).toHaveLength(201) // 200 + …
|
||||
expect(preview.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s'))
|
||||
const before = session.getSnapshot().queue
|
||||
session.handleAgentError('unrelated') // dirties the snapshot without touching the queue
|
||||
expect(session.getSnapshot().queue).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue retirement (host queuedMirror rules)', () => {
|
||||
it('a message-triggered turn/start claims the oldest non-steering row', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1'))
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2'))
|
||||
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) })
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2'])
|
||||
})
|
||||
|
||||
it('an injection-triggered turn/start claims nothing', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1'))
|
||||
const injection = {
|
||||
...ev.turnStart(0, 0),
|
||||
data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } },
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection })
|
||||
expect(session.getSnapshot().queue).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('steering/message drains the source-matched steering row only', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1'))
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('插话', 'p-2', true))
|
||||
// Loop-authored steering (different source) must not consume the user entry.
|
||||
const foreignSteering = {
|
||||
seq: 0, time: 1,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } },
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: foreignSteering })
|
||||
expect(session.getSnapshot().queue).toHaveLength(2)
|
||||
const matchedSteering = {
|
||||
seq: 1, time: 2,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } },
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: matchedSteering })
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
|
||||
})
|
||||
|
||||
it('a leave-running flip sweeps the whole mirror (cancel/terminal-drop cover)', () => {
|
||||
const session = makeSession()
|
||||
session.handleRunning(true)
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1'))
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2', true))
|
||||
session.handleRunning(false)
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
})
|
||||
|
||||
it('a stale not-running relay on an idle session still sweeps replayed rows', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('孤儿', 'p-1'))
|
||||
session.handleRunning(false) // running already false: equality path must not skip the sweep
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue reconnect semantics', () => {
|
||||
it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old'))
|
||||
// New mux generation: subscribed arrives first on the same stream...
|
||||
session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
// ...then the queue snapshot replays the live inbox.
|
||||
session.handleMuxEnvelope(rid('e3'), queuedFrame('新基线', 'p-new'))
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-new'])
|
||||
})
|
||||
|
||||
it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => {
|
||||
const session = makeSession()
|
||||
// Reconnect ordering that broke: mux opened first and already delivered
|
||||
// the fresh generation's baseline; host stream (and with it onConnected →
|
||||
// resync) lands after. The host never resends — clearing here left the
|
||||
// dock empty until the next enqueue.
|
||||
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('新基线', 'p-fresh'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('manager buffering of queued frames', () => {
|
||||
it('buffers session/queued for uninstantiated sessions and replays before the running sync', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queuedFrame('预热', 'p-b1') })
|
||||
// Instantiation replays the buffer; no summary exists, so no running sweep runs.
|
||||
const session = manager.get(SID)
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-b1'])
|
||||
// The buffer is consumed: a second get must not double-replay.
|
||||
expect(manager.get(SID).getSnapshot().queue).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a not-running list summary sweeps replayed rows at instantiation', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok([{ sessionId: SID, updatedAt: 1, running: false }]))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queuedFrame('该扫掉', 'p-b2') })
|
||||
expect(manager.get(SID).getSnapshot().queue).toEqual([])
|
||||
})
|
||||
|
||||
it('subscribed re-baselines the uninstantiated buffer: stale queued frames drop, non-queue frames survive (regression: reconnect duplication)', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// Generation 1 baseline lands while the session is uninstantiated, along
|
||||
// with a pending approval (never re-derivable from history).
|
||||
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queuedFrame('第一代', 'p-g1') })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: rid('g1b'),
|
||||
payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
|
||||
})
|
||||
// Reconnect: generation 2 replays subscribed + the SAME live queue entry.
|
||||
manager.handleMuxEnvelope({ rpcId: rid('g2a'), payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 } })
|
||||
manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queuedFrame('第一代', 'p-g1') })
|
||||
const snapshot = manager.get(SID).getSnapshot()
|
||||
// One queue row (no duplicate batch); the approval survived the re-baseline.
|
||||
expect(snapshot.queue.map(r => r.key)).toEqual(['p-g1'])
|
||||
expect(snapshot.pending.map(p => p.kind)).toEqual(['approval'])
|
||||
})
|
||||
})
|
||||
|
||||
/** ok wrapper with a typed items payload (the shared helper pins value to never[]). */
|
||||
function ok(items: { sessionId: SessionId; updatedAt: number; running: boolean }[]) {
|
||||
return { rpcId: rid(`ok-${items.length}`), result: { ok: true as const, value: { items: items as never[] } } }
|
||||
}
|
||||
84
packages/client/runtime/tests/scope.spec.ts
Normal file
84
packages/client/runtime/tests/scope.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Agent-scope primitive spec: the actx minted by createScope carries the
|
||||
* tag and the dispatch filter itself, so plain cordis dispatch with the actx
|
||||
* as subject routes by agent — same-agent tagged listeners receive,
|
||||
* foreign-agent ones are filtered out, untagged listeners hear everything,
|
||||
* and a subject-less root dispatch stays unfiltered. Scope-owned listeners
|
||||
* dispose with the fiber.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createScope, scopeOf } from '../src/client/agents/scope.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only routed probe event.
|
||||
* @param payload - marker payload.
|
||||
* @mode bail
|
||||
*/
|
||||
'test/scope-probe'(payload: { from: string }): true | undefined
|
||||
}
|
||||
}
|
||||
|
||||
function bench() {
|
||||
const root = new Context()
|
||||
const a = createScope(root, sid('a'))
|
||||
const b = createScope(root, sid('b'))
|
||||
const seen: string[] = []
|
||||
const listen = (label: string, ctx: Context, answer?: true) => {
|
||||
ctx.on('test/scope-probe', (payload) => {
|
||||
seen.push(`${label}:${payload.from}`)
|
||||
return answer
|
||||
})
|
||||
}
|
||||
return { root, a, b, seen, listen }
|
||||
}
|
||||
|
||||
describe('createScope', () => {
|
||||
it('tags the ctx (scopeOf) and leaves the root untagged', () => {
|
||||
const { root, a } = bench()
|
||||
expect(scopeOf(a.ctx)).toBe(sid('a'))
|
||||
expect(scopeOf(root)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('scoped dispatch reaches same-session and untagged listeners, never a foreign session', () => {
|
||||
const { root, a, b, seen, listen } = bench()
|
||||
listen('a', a.ctx)
|
||||
listen('b', b.ctx)
|
||||
listen('root', root)
|
||||
a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })
|
||||
expect(seen).toEqual(['a:a', 'root:a'])
|
||||
seen.length = 0
|
||||
b.ctx.emit(b.ctx, 'test/scope-probe', { from: 'b' })
|
||||
expect(seen).toEqual(['b:b', 'root:b'])
|
||||
})
|
||||
|
||||
it('bail answers the first same-scope listener and skips filtered foreign ones', () => {
|
||||
const { a, b, listen } = bench()
|
||||
listen('b', b.ctx, true) // registered first, but foreign → filtered out
|
||||
expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBeUndefined()
|
||||
listen('a', a.ctx, true)
|
||||
expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBe(true)
|
||||
})
|
||||
|
||||
it('a subject-less root dispatch is unfiltered (every listener hears it)', () => {
|
||||
const { root, a, b, seen, listen } = bench()
|
||||
listen('a', a.ctx)
|
||||
listen('b', b.ctx)
|
||||
listen('root', root)
|
||||
root.emit('test/scope-probe', { from: 'root' })
|
||||
expect(seen).toEqual(['a:root', 'b:root', 'root:root'])
|
||||
})
|
||||
|
||||
it('fiber disposal removes scope-owned listeners', async () => {
|
||||
const { a, seen, listen } = bench()
|
||||
listen('a', a.ctx)
|
||||
await a.fiber.dispose()
|
||||
a.ctx.emit(a.ctx, 'test/scope-probe', { from: 'late' })
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,191 +0,0 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
const wid = (id: string): WorkspaceId => id as WorkspaceId
|
||||
|
||||
function workspace(id: string, sessionIds: SessionId[] = []): WorkspaceView {
|
||||
return {
|
||||
workspaceId: wid(id),
|
||||
path: `/w/${id}`,
|
||||
title: id,
|
||||
sessionIds,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
async function ready(
|
||||
api: FakeApiClient,
|
||||
workspaces: WorkspacesService,
|
||||
sessions: SessionsService,
|
||||
workspaceRows: WorkspaceView[],
|
||||
sessionRows: { sessionId: SessionId; updatedAt: number; running: boolean }[] = [],
|
||||
): Promise<void> {
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: workspaceRows as never[] }))
|
||||
api.onList = () => Promise.resolve(ok({ items: sessionRows as never[] }))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function services(api: FakeApiClient): { sessions: SessionsService; workspaces: WorkspacesService } {
|
||||
const ctx = new Context()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
return { sessions, workspaces }
|
||||
}
|
||||
|
||||
function pendingPrompt(sessions: SessionsService, sessionId: SessionId) {
|
||||
return sessions.binding(sessionId)?.session.getSnapshot().pendingPrompt
|
||||
}
|
||||
|
||||
describe('frontend Session and Workspace intents', () => {
|
||||
it('resolves the initial intent into the most recently active Workspace', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
const old = workspace('old', [sid('s-old')])
|
||||
const recent = workspace('recent', [sid('s-recent')])
|
||||
await ready(api, workspaces, sessions, [old, recent], [
|
||||
{ sessionId: sid('s-old'), updatedAt: 1, running: false },
|
||||
{ sessionId: sid('s-recent'), updatedAt: 2, running: false },
|
||||
])
|
||||
expect(sessions.list.getSnapshot().intent).toMatchObject({
|
||||
target: { kind: 'workspace', workspaceId: 'recent' },
|
||||
phase: 'ready',
|
||||
})
|
||||
expect(workspaces.list.getSnapshot().intent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
await ready(api, workspaces, sessions, [])
|
||||
expect(workspaces.list.getSnapshot().intent).toMatchObject({ name: 'workspace', phase: 'ready' })
|
||||
sessions.updateIntent('first prompt')
|
||||
api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('created'), created: true }))
|
||||
api.onCreate = payload => Promise.resolve(ok({
|
||||
sessionId: (payload as { sessionId: SessionId }).sessionId,
|
||||
}))
|
||||
api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'prompt offline', details: {} }))
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => {
|
||||
const sessionId = sessions.list.getSnapshot().current as SessionId
|
||||
expect(pendingPrompt(sessions, sessionId)).toMatchObject({
|
||||
text: 'first prompt', phase: 'failed', retry: 'send',
|
||||
})
|
||||
})
|
||||
expect(api.callsOf('workspace.create')).toEqual([{ name: 'workspace' }])
|
||||
const create = api.callsOf('session.create')[0] as { workspaceId: WorkspaceId; sessionId: SessionId }
|
||||
expect(create.workspaceId).toBe('created')
|
||||
expect(api.callsOf('session.prompt')).toEqual([{
|
||||
sessionId: create.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'first prompt' }],
|
||||
}])
|
||||
expect(workspaces.list.getSnapshot().intent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('turns Workspace attachment failure into a focused real Session and retries its prompt', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
const target = workspace('target')
|
||||
await ready(api, workspaces, sessions, [target])
|
||||
sessions.updateIntent('keep this')
|
||||
api.onCreate = (payload) => {
|
||||
const sessionId = (payload as { sessionId: SessionId }).sessionId
|
||||
return Promise.resolve(err({
|
||||
code: 'workspace-attach-failed',
|
||||
message: 'attach rejected',
|
||||
details: { sessionId, workspaceId: target.workspaceId },
|
||||
}))
|
||||
}
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => {
|
||||
const snapshot = sessions.list.getSnapshot()
|
||||
expect(snapshot.intent).toBeUndefined()
|
||||
expect(pendingPrompt(sessions, snapshot.current as SessionId)).toMatchObject({
|
||||
text: 'keep this', phase: 'failed', retry: 'connect',
|
||||
})
|
||||
})
|
||||
const published = sessions.list.getSnapshot().current as SessionId
|
||||
const session = sessions.binding(published)!.session
|
||||
session.updatePendingPrompt('retry this')
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: published }))
|
||||
session.retryPendingPrompt()
|
||||
await vi.waitFor(() => {
|
||||
expect(pendingPrompt(sessions, published)).toBeNull()
|
||||
})
|
||||
expect(api.callsOf('session.prompt').at(-1)).toMatchObject({
|
||||
sessionId: published,
|
||||
content: [{ type: 'text', text: 'retry this' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not send after navigation while Session creation is in flight', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
const target = workspace('target')
|
||||
await ready(api, workspaces, sessions, [target])
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onCreate']>>>()
|
||||
api.onCreate = () => gate.promise
|
||||
sessions.updateIntent('do not send yet')
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => { expect(api.callsOf('session.create')).toHaveLength(1) })
|
||||
const requested = (api.callsOf('session.create')[0] as { sessionId: SessionId }).sessionId
|
||||
workspaces.startSession(target.workspaceId)
|
||||
const replacement = sessions.list.getSnapshot().intent!
|
||||
gate.resolve(ok({ sessionId: requested }))
|
||||
await vi.waitFor(() => {
|
||||
expect(pendingPrompt(sessions, requested)).toMatchObject({
|
||||
text: 'do not send yet', phase: 'failed', retry: 'send',
|
||||
})
|
||||
})
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
expect(sessions.list.getSnapshot()).toMatchObject({
|
||||
current: replacement.sessionId,
|
||||
intent: { sessionId: replacement.sessionId },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a lost-response Intent and retries creation with its preallocated id', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
const target = workspace('target')
|
||||
await ready(api, workspaces, sessions, [target])
|
||||
sessions.updateIntent('preserve me')
|
||||
api.onCreate = () => Promise.reject(new Error('response lost'))
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => {
|
||||
expect(sessions.list.getSnapshot().intent?.error).toMatchObject({ step: 'session' })
|
||||
})
|
||||
const requested = sessions.list.getSnapshot().intent?.sessionId as SessionId
|
||||
sessions.handleHostEnvelope({
|
||||
rpcId: 'published-later' as never,
|
||||
payload: { type: 'host/session-added', sessionId: requested, cwd: target.path },
|
||||
})
|
||||
expect(sessions.list.getSnapshot()).toMatchObject({
|
||||
current: requested,
|
||||
intent: { sessionId: requested, error: { step: 'session' } },
|
||||
})
|
||||
expect(sessions.intent()?.getSnapshot().pendingPrompt).toMatchObject({
|
||||
text: 'preserve me', phase: 'editing',
|
||||
})
|
||||
|
||||
api.onCreate = payload => Promise.resolve(ok({
|
||||
sessionId: (payload as { sessionId: SessionId }).sessionId,
|
||||
}))
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.create')).toHaveLength(2)
|
||||
expect(api.callsOf('session.prompt')).toHaveLength(1)
|
||||
expect(sessions.list.getSnapshot()).toMatchObject({ current: requested, intent: undefined })
|
||||
expect(pendingPrompt(sessions, requested)).toBeNull()
|
||||
})
|
||||
expect(api.callsOf('session.create').map(call => (call as { sessionId: SessionId }).sessionId))
|
||||
.toEqual([requested, requested])
|
||||
})
|
||||
})
|
||||
@@ -659,6 +659,95 @@ describe('resync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch indexing', () => {
|
||||
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', 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.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
|
||||
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
|
||||
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
|
||||
const live = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(live).toHaveLength(2)
|
||||
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
|
||||
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
|
||||
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
|
||||
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
|
||||
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
|
||||
const mixed = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
|
||||
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
|
||||
// The settle carries the paired start's time as callTime (duration source).
|
||||
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
|
||||
const settled = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
|
||||
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
|
||||
})
|
||||
|
||||
it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', 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.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
|
||||
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
|
||||
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
|
||||
const subs = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(subs).toHaveLength(2)
|
||||
expect(subs?.[0]).toMatchObject({
|
||||
kind: 'tool-result', callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' },
|
||||
// The settle event carries no start time: callTime stays null (never a
|
||||
// fabricated zero-duration).
|
||||
callTime: null,
|
||||
isError: false, content: [{ type: 'text', text: 'demo.txt' }],
|
||||
})
|
||||
expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true })
|
||||
// No paired start in the window: duration is UNKNOWN (null), never a
|
||||
// fabricated zero-duration span.
|
||||
expect(subs?.[0]).toMatchObject({ callTime: null })
|
||||
// Sub-dispatches never join the surface flow.
|
||||
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('rebuilds the same index from a history window (replay parity)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse([
|
||||
...plainTurn(0, 0, '问', '答'),
|
||||
ev.turnStart(6, 1),
|
||||
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
|
||||
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'),
|
||||
ev.toolResult(9, 1, 'p1', '{"done":true}'),
|
||||
ev.turnEnd(10, 1),
|
||||
])
|
||||
await session.open()
|
||||
const subs = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(subs).toHaveLength(1)
|
||||
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
|
||||
})
|
||||
|
||||
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', 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.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
|
||||
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
|
||||
const before = session.getSnapshot()
|
||||
feed(ev.chunkStart(9, 1))
|
||||
feed(ev.chunkText(10, 1, '流式'))
|
||||
const after = session.getSnapshot()
|
||||
expect(after.codeDispatches).toBe(before.codeDispatches)
|
||||
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
|
||||
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
|
||||
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reference stability (the memo contract)', () => {
|
||||
it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
|
||||
const { api, session } = makeSession()
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, deferred, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
@@ -28,10 +28,12 @@ function bench(): Bench {
|
||||
}
|
||||
|
||||
/** Refresh the manager list from programmable rows and flush the microtask batch. */
|
||||
async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean }[]): Promise<void> {
|
||||
type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
|
||||
|
||||
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
b.api.onList = () => Promise.resolve(ok({
|
||||
items: rows.map(r => ({
|
||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
|
||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
|
||||
})),
|
||||
@@ -61,7 +63,7 @@ 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', sessionId: sid('s2') } as never })
|
||||
b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', blank: true, sessionId: sid('s2') } as never })
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().ids).toContain('s2')
|
||||
})
|
||||
@@ -77,7 +79,7 @@ describe('scope tree', () => {
|
||||
expect(scopeOf(scoped as Context)).toBe('s1')
|
||||
expect(scopeOf(b.ctx)).toBeUndefined()
|
||||
const binding = b.svc.binding(sid('s1'))
|
||||
expect(binding?.session).toBe(b.svc.cell('s1')?.session)
|
||||
expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
|
||||
expect(b.svc.binding(sid('s1'))).toBe(binding)
|
||||
expect(binding?.ctx).toBe(scoped)
|
||||
})
|
||||
@@ -184,20 +186,20 @@ describe('cell (render-layer session kit)', () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const cell = b.svc.cell('s1')
|
||||
expect(cell).toBeDefined()
|
||||
expect(cell?.sessionId).toBe('s1')
|
||||
// The cell carries the observable; hook binding happens in React.
|
||||
expect(cell?.session).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(b.svc.cell('s1')).toBe(cell)
|
||||
expect(b.svc.cell('ghost')).toBeUndefined()
|
||||
const info = b.svc.provideInfo('s1')
|
||||
expect(info).toBeDefined()
|
||||
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)
|
||||
expect(b.svc.provideInfo('s1')).toBe(info)
|
||||
expect(b.svc.provideInfo('ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.open(sid('s1')) // staged
|
||||
b.svc.cell('s2') // resolution only — must NOT move the stage
|
||||
b.svc.provideInfo('s2') // resolution only — must NOT move the stage
|
||||
b.svc.binding(sid('s2'))
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
@@ -209,7 +211,7 @@ describe('cell (render-layer session kit)', () => {
|
||||
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
|
||||
// Resolution is addressing, not staging: no window pull.
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.cell('s1')
|
||||
b.svc.provideInfo('s1')
|
||||
b.svc.binding(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(0)
|
||||
b.svc.open(sid('s1'))
|
||||
@@ -296,12 +298,24 @@ describe('create', () => {
|
||||
const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(SessionCreateError)
|
||||
expect(failure).toMatchObject({
|
||||
requestedSessionId: 'candidate', publishedSessionId: undefined,
|
||||
requestedSessionId: 'candidate',
|
||||
rpcError: { code: 'internal', message: '爆了' },
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces the definitely published id after Workspace attachment fails', async () => {
|
||||
it('resolves with the session already listed and binding-resolvable (no flush wait)', async () => {
|
||||
const b = bench()
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('born') }))
|
||||
const born = await b.svc.create({ workspaceId: 'ws' as never })
|
||||
// Synchronously after resolution — the draft hand-off contract: the
|
||||
// create echo IS the entity entering the client's view (blank row +
|
||||
// resolvable scope/binding), no notifier flush in between.
|
||||
expect(b.svc.list.getSnapshot().byId[born]).toMatchObject({ id: 'born', blank: true })
|
||||
expect(b.svc.binding(born)).toBeDefined()
|
||||
expect(b.svc.scope(born)).toBeDefined()
|
||||
})
|
||||
|
||||
it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => {
|
||||
const b = bench()
|
||||
b.api.onCreate = () => Promise.resolve({
|
||||
rpcId: 'attach' as never,
|
||||
@@ -318,11 +332,111 @@ describe('create', () => {
|
||||
sessionId: sid('published'),
|
||||
}).catch((error: unknown) => error)
|
||||
await Promise.resolve()
|
||||
expect(failure).toBeInstanceOf(SessionCreateError)
|
||||
expect(failure).toMatchObject({
|
||||
publishedSessionId: 'published', requestedSessionId: 'published',
|
||||
requestedSessionId: 'published',
|
||||
rpcError: { code: 'workspace-attach-failed' },
|
||||
})
|
||||
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published' })
|
||||
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
|
||||
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
|
||||
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,
|
||||
})
|
||||
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') },
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(b.svc.scope(sid('s-new'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('blank mirror', () => {
|
||||
it('flips blank=false from the running:true status frame (cross-client conversion)', async () => {
|
||||
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 },
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true })
|
||||
// The instantiated Session mirrors the same flip.
|
||||
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
|
||||
})
|
||||
|
||||
it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
|
||||
const session = b.svc.binding(sid('s1'))!.session
|
||||
expect(session.getSnapshot().blank).toBe(true)
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onPrompt']>>>()
|
||||
b.api.onPrompt = () => gate.promise
|
||||
const send = session.prompt([{ type: 'text', text: 'hi' }], 'queue')
|
||||
// In flight: still blank (the flip point is the success response, which
|
||||
// proves the user message reached the host log).
|
||||
expect(session.getSnapshot().blank).toBe(true)
|
||||
gate.resolve(ok({ accepted: true as const }))
|
||||
await send
|
||||
expect(session.getSnapshot().blank).toBe(false)
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
|
||||
})
|
||||
|
||||
it('keeps a rejected first prompt blank: hidden and still reusable', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
|
||||
const session = b.svc.binding(sid('s1'))!.session
|
||||
b.api.onPrompt = () => Promise.resolve({
|
||||
rpcId: 'busy' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } },
|
||||
} as never)
|
||||
const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
|
||||
expect(result.ok).toBe(false)
|
||||
// No flip on failure: local stays aligned with the host authority
|
||||
// (events.length still 0), so the session stays hidden and reusable.
|
||||
expect(session.getSnapshot().blank).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true })
|
||||
// Reconnect re-pull: the summary's blank=false wins (authoritative alignment).
|
||||
await feedList(b, [{ id: 's-new', blank: false, cwd: '/w/a' }])
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: false })
|
||||
})
|
||||
|
||||
it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true }])
|
||||
const session = b.svc.binding(sid('s1'))!.session
|
||||
await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
|
||||
// The next list pull still claims blank (host hasn't logged the message yet).
|
||||
await feedList(b, [{ id: 's1', blank: true }])
|
||||
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -97,13 +97,17 @@ function fakeWorkspaces() {
|
||||
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
|
||||
}
|
||||
|
||||
/** Minimal sessions face for the host seam (list observable + cell). */
|
||||
/** Minimal sessions face for the host seam (list observable + provide bundle). */
|
||||
function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
return {
|
||||
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||
cell: (id: string) => (id === 'known'
|
||||
? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
|
||||
provideInfo: (id: string) => (id === 'known'
|
||||
? {
|
||||
sessionId: id,
|
||||
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
|
||||
props: {},
|
||||
}
|
||||
: undefined),
|
||||
}
|
||||
}
|
||||
@@ -228,13 +232,13 @@ describe('host face', () => {
|
||||
expect(host.entriesOf('t.host')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('exposes sessions list/current/cell (current riding the list snapshot)', async () => {
|
||||
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench)
|
||||
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
|
||||
expect(host.sessions.current.getSnapshot()).toBeUndefined()
|
||||
expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.cell('ghost')).toBeUndefined()
|
||||
expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes the independent Workspace list source', async () => {
|
||||
|
||||
55
packages/client/runtime/tests/wire-events.spec.ts
Normal file
55
packages/client/runtime/tests/wire-events.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge (web input-triggers cut 1): host/commands-changed
|
||||
* → ctx 'commands/changed'; each established connection generation →
|
||||
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
sinks: ConnectionSinks | undefined
|
||||
}
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
describe('wire event bridge', () => {
|
||||
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
|
||||
const bench = await mount()
|
||||
let changed = 0
|
||||
bench.ctx.on('commands/changed', () => { changed++ })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
|
||||
expect(changed).toBe(1)
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r2' as never,
|
||||
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
|
||||
})
|
||||
expect(changed).toBe(1)
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
bench.ctx.on('connection/reset', () => { resets++ })
|
||||
bench.sinks?.onConnected?.()
|
||||
bench.sinks?.onConnected?.() // second generation after a reconnect
|
||||
expect(resets).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -17,38 +17,6 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0
|
||||
}
|
||||
|
||||
describe('WorkspaceManager', () => {
|
||||
it('owns, materializes, retries, supersedes, and discards Workspace objects with local intents', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new WorkspaceManager(api)
|
||||
manager.startIntent('first')
|
||||
expect(manager.getSnapshot().intent).toEqual({ name: 'first', phase: 'ready' })
|
||||
|
||||
api.onWorkspaceCreate = () => Promise.resolve(err({
|
||||
code: 'workspace-name-conflict', message: 'taken', details: { name: 'first' },
|
||||
} as never))
|
||||
await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: false })
|
||||
expect(manager.getSnapshot().intent).toMatchObject({ name: 'first', phase: 'ready' })
|
||||
expect(typeof manager.getSnapshot().intent?.error).toBe('string')
|
||||
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceCreate']>>>()
|
||||
api.onWorkspaceCreate = () => gate.promise
|
||||
const stale = manager.materializeIntent()
|
||||
expect(manager.getSnapshot().intent?.phase).toBe('creating')
|
||||
manager.startIntent('replacement')
|
||||
gate.resolve(ok({ workspace: workspace('first'), created: true }))
|
||||
await stale
|
||||
expect(manager.getSnapshot().intent).toEqual({ name: 'replacement', phase: 'ready' })
|
||||
|
||||
api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('replacement'), created: true }))
|
||||
await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: true })
|
||||
expect(manager.getSnapshot().intent).toBeUndefined()
|
||||
await expect(manager.materializeIntent()).resolves.toBeUndefined()
|
||||
manager.discardIntent()
|
||||
manager.startIntent('discarded')
|
||||
manager.discardIntent()
|
||||
expect(manager.getSnapshot().intent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replays changed frames over hydration and keeps established order on refresh', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
@@ -111,7 +79,7 @@ describe('WorkspaceManager', () => {
|
||||
})
|
||||
|
||||
describe('WorkspacesService', () => {
|
||||
it('feeds SessionManager readiness and recent-Workspace targeting without changing Host order', async () => {
|
||||
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)
|
||||
@@ -127,7 +95,7 @@ describe('WorkspacesService', () => {
|
||||
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 }] as never[],
|
||||
items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false, blank: false }] as never[],
|
||||
}))
|
||||
await sessions.refresh()
|
||||
await Promise.resolve()
|
||||
@@ -136,12 +104,65 @@ describe('WorkspacesService', () => {
|
||||
baselinesReady: true,
|
||||
recentWorkspaceId: 'active',
|
||||
})
|
||||
expect(sessions.list.getSnapshot().intent).toMatchObject({
|
||||
target: { kind: 'workspace', workspaceId: 'active' },
|
||||
})
|
||||
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
|
||||
})
|
||||
|
||||
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('alpha'), workspace('beta')] as never[],
|
||||
}))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
// Blank session already parked in alpha (cwd == workspace path canon).
|
||||
{ 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' },
|
||||
] as never[],
|
||||
}))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
|
||||
// Hit: same workspace → the parked blank session comes back, 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()
|
||||
|
||||
// Unknown workspace fails loud instead of silently creating in nowhere.
|
||||
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
|
||||
})
|
||||
|
||||
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)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] 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()
|
||||
|
||||
Reference in New Issue
Block a user