Merge remote-tracking branch 'origin/doc/host-client-group-readmes' into feat/directory-picker
# Conflicts: # packages/client/connection/src/client/fixture.ts # packages/client/connection/tests/fake-api.ts # packages/client/runtime/src/client/workspaces/service.ts # packages/client/runtime/tests/fake-api.ts # packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx # packages/client/ui-workspace/src/client/WorkspacePicker.tsx # packages/client/ui-workspace/tests/workspace-picker.spec.tsx # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/host.schema.ts # packages/host/apiproxy/src/api/host.ts # packages/host/apiproxy/src/api/rpc-map.ts # packages/host/apiproxy/src/fetch/client.ts # packages/host/apiproxy/src/fetch/handler.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/host/apiproxy/tests/client-handler.spec.ts # packages/host/apiproxy/tests/fetch-carrier.spec.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499
|
||||
README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200
|
||||
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
|
||||
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; 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. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; 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. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
@@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
|
||||
## 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 (`provideInfo()`/`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 (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[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。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值(host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`,因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。
|
||||
客户端 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。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
@@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`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 列表。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。
|
||||
|
||||
@@ -32,10 +32,13 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"immer": "^10.1.1",
|
||||
"react": "^18.2.0",
|
||||
"zustand": "~4.4.7"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
import type { UseProjection } from './sessions/projection-store.ts'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
@@ -30,12 +31,17 @@ export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
// Projection value store (session-projection RFC, push model): host-computed
|
||||
// whole values per key; domains ship projection support with zero client code.
|
||||
export type {
|
||||
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
|
||||
} from './sessions/projection-store.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
@@ -61,12 +67,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
/** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */
|
||||
useProjection: UseProjection
|
||||
}
|
||||
/** Standard kit for slots that remain mounted while current session changes. */
|
||||
interface SessionMaybeStandardProps {
|
||||
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
|
||||
/** Current session id; absent in the no-session state. */
|
||||
sessionId: SessionId | undefined
|
||||
/** Key-addressed projection reader; every key reads absent while no session is current. */
|
||||
useProjection: UseProjection
|
||||
}
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
@@ -120,6 +121,31 @@ export interface UnknownSurfaceNode {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* One slash-command lifecycle folded from the log-only `command/run` /
|
||||
* `command/done` pair (paired by commandId, mirroring tool call↔result).
|
||||
* Log-only events never enter the surface fold, so the FoldAdapter indexes
|
||||
* them separately and merges the nodes into the flow by seq. A window cut
|
||||
* between the pair soft-falls like tool pairs: a done with no in-window run
|
||||
* still builds a node (name/args null), and a run with no done renders as
|
||||
* still executing.
|
||||
*/
|
||||
export interface CommandNode {
|
||||
kind: 'command'
|
||||
/** Seq of the command/run event; the done event's seq when only the done is in-window. */
|
||||
seq: number
|
||||
/** Unix epoch ms of the anchoring event. */
|
||||
time: number
|
||||
/** Pairing id minted by the host executor. */
|
||||
commandId: CommandId
|
||||
/** Command name (run payload's structured field); null when the run fell outside the window. */
|
||||
name: string | null
|
||||
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
|
||||
args: string | null
|
||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
export type ConversationNode =
|
||||
| UserMessageNode
|
||||
@@ -127,6 +153,7 @@ export type ConversationNode =
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| ToolResultNode
|
||||
| CommandNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
@@ -243,7 +270,4 @@ export interface ConversationSnapshot {
|
||||
*/
|
||||
blank: boolean
|
||||
lastAgentError: string | null
|
||||
/** Current whole-list `todo/write` projection — the tail page's full-log value, then each live
|
||||
* write (last write wins); empty = the log holds no plan. */
|
||||
todos: readonly TodoItem[]
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// go through it — the package root points at lib/index.js (needs a build) which the vite
|
||||
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { CommandNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
|
||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||
@@ -56,21 +57,23 @@ function materializeNode(
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.content, source: event.data.source,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const call = callIndex.get(String(event.data.callId))
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
const call = callIndex.get(callId)
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: String(event.data.callId),
|
||||
callId,
|
||||
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
|
||||
callTime: call?.time ?? null,
|
||||
content: event.data.content, isError: event.data.isError,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error !== undefined ? { error: event.data.error } : {}),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
@@ -99,6 +102,15 @@ export class FoldAdapter {
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/**
|
||||
* Command lifecycle nodes by commandId (insertion = run order). The
|
||||
* `command/run`/`command/done` pair is log-only, so the surface fold never
|
||||
* emits it; this index folds the pair (done settles its run's node in
|
||||
* place) and nodes() merges the products into the flow by seq. Window cuts
|
||||
* soft-fall like tool pairs: a done with no in-window run still builds a
|
||||
* node.
|
||||
*/
|
||||
private commandIdx = new Map<string, CommandNode>()
|
||||
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
|
||||
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
|
||||
* reference-stability contract (§A.9.4) starts here. */
|
||||
@@ -128,10 +140,14 @@ export class FoldAdapter {
|
||||
this.degraded = false
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (event !== undefined) this.indexCall(event, views?.[i])
|
||||
if (event !== undefined) {
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +161,7 @@ export class FoldAdapter {
|
||||
this.rev++
|
||||
this.padded.push(event)
|
||||
this.indexCall(event, view)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +197,23 @@ export class FoldAdapter {
|
||||
this.nodeCache.set(seq, node)
|
||||
out.push(node)
|
||||
}
|
||||
const value = { nodes: out, degraded: this.degraded }
|
||||
// Command nodes fold outside the surface (log-only events); merge by seq.
|
||||
// Both inputs are seq-ascending (surface order and run-index insertion
|
||||
// order share the log order), so one linear merge keeps flow order.
|
||||
let nodes = out
|
||||
if (this.commandIdx.size > 0) {
|
||||
nodes = []
|
||||
const commands = [...this.commandIdx.values()]
|
||||
let next = 0
|
||||
for (const node of out) {
|
||||
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
|
||||
nodes.push(cmd)
|
||||
}
|
||||
nodes.push(node)
|
||||
}
|
||||
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
|
||||
}
|
||||
const value = { nodes, degraded: this.degraded }
|
||||
this.nodesResult = { rev: this.rev, value }
|
||||
return value
|
||||
}
|
||||
@@ -195,6 +228,36 @@ export class FoldAdapter {
|
||||
return seqs
|
||||
}
|
||||
|
||||
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
|
||||
private indexCommand(event: SessionEvent): void {
|
||||
// Log-only plugin events: the host-side dsh-commands declaration cannot
|
||||
// enter the client program, so this wire consumer narrows structurally
|
||||
// (the same posture as tool/code-dispatch in session.ts).
|
||||
if ((event.type as string) === 'command/run') {
|
||||
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
|
||||
this.commandIdx.set(data.commandId, {
|
||||
kind: 'command', seq: event.seq, time: event.time,
|
||||
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if ((event.type as string) !== 'command/done') return
|
||||
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
|
||||
const run = this.commandIdx.get(data.commandId)
|
||||
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
|
||||
if (run === undefined) {
|
||||
// Cross-window cut: the run page fell out of the window — build the
|
||||
// node from the done alone (same soft-fall as a call-less tool result).
|
||||
this.commandIdx.set(data.commandId, {
|
||||
kind: 'command', seq: event.seq, time: event.time,
|
||||
commandId: data.commandId, name: null, args: null, outcome,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Settle in place: a fresh node object (published references stay immutable).
|
||||
this.commandIdx.set(data.commandId, { ...run, outcome })
|
||||
}
|
||||
|
||||
private indexCall(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (event.type === 'tool/result') {
|
||||
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
||||
|
||||
@@ -9,7 +9,12 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
// Type-only merge edge: the title domain's client-namespace outlet declares
|
||||
// the 'title' projection key this manager projects into list rows (and any
|
||||
// useProjection('title') consumer reads). Zero value imports by construction.
|
||||
import type {} from '@deepseek-ai/dsh-session-title/client'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import { Session } from './session.ts'
|
||||
|
||||
/**
|
||||
@@ -43,12 +48,6 @@ type SessionListMutation =
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
|
||||
/** Latest title control snapshot retained independently of list/instance arrival. */
|
||||
interface SessionTitleSnapshot {
|
||||
title: string
|
||||
eventSeq: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
export class SessionManager {
|
||||
@@ -58,7 +57,11 @@ export class SessionManager {
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
|
||||
/** Per-session projection value stores, retained independently of instance arrival (the
|
||||
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
||||
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
||||
* same store so history-baseline seeding and frames converge on one row set. */
|
||||
private readonly projectionStores = new Map<SessionId, ProjectionValueStore>()
|
||||
private summaries: SessionSummary[] = []
|
||||
private listState: 'idle' | 'loading' | 'error' = 'idle'
|
||||
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
|
||||
@@ -163,9 +166,23 @@ export class SessionManager {
|
||||
onEngaged: (engaged) => {
|
||||
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
|
||||
},
|
||||
projections: this.projectionStore(sessionId),
|
||||
})
|
||||
}
|
||||
|
||||
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
|
||||
private projectionStore(sessionId: SessionId): ProjectionValueStore {
|
||||
let store = this.projectionStores.get(sessionId)
|
||||
if (store === undefined) {
|
||||
store = new ProjectionValueStore()
|
||||
// List rows project off store keys (title); any-key changes re-enter
|
||||
// the manager's own batched rebuild channel.
|
||||
store.subscribeAny(() => { this.notifier.markDirty() })
|
||||
this.projectionStores.set(sessionId, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
@@ -302,23 +319,20 @@ export class SessionManager {
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
|
||||
if (frame.type === 'session/title') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
|
||||
this.titleSnapshots.set(frame.sessionId, {
|
||||
title: frame.title,
|
||||
eventSeq: frame.eventSeq,
|
||||
updatedAt: frame.updatedAt,
|
||||
})
|
||||
if (frame.type === 'session/projection') {
|
||||
// Finished host-computed value: land it in the resident store whether or
|
||||
// not the Session is instantiated (list rows read the 'title' key). The
|
||||
// synchronous markDirty keeps the list snapshot same-tick fresh (the
|
||||
// store's own any-key channel is microtask-batched).
|
||||
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/subscribed') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq > frame.lastSeq) {
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
// Rows past the host's durable baseline rode state a restart lost; drop
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
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
|
||||
@@ -377,7 +391,7 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
return
|
||||
}
|
||||
case 'host/session-status': {
|
||||
@@ -402,10 +416,12 @@ export class SessionManager {
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
const title = this.titleSnapshots.get(summary.sessionId)
|
||||
return title === undefined
|
||||
? summary
|
||||
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
// value; the bespoke session/title frame is retired).
|
||||
const title = this.projectionStores.get(summary.sessionId)?.get('title')
|
||||
return typeof title === 'string' && title !== ''
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
})
|
||||
const fresh = flattenLineage(merged)
|
||||
const items = fresh.map((entry) => {
|
||||
|
||||
183
packages/client/runtime/src/client/sessions/projection-store.ts
Normal file
183
packages/client/runtime/src/client/sessions/projection-store.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Generic per-session projection value store (session-projection RFC, push
|
||||
* model): the host is the only computation site; the client holds finished
|
||||
* whole values per key — `key → { value, seq }` — seeded by the history tail
|
||||
* page's projections block and updated by `session/projection` push frames,
|
||||
* under the single rule **higher seq wins**. No client-side domain folding
|
||||
* exists: a domain ships projection support with zero client code. Per-key
|
||||
* bare observable faces feed `useProjection` (web-react binds them).
|
||||
*/
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
|
||||
// The single projection type table, typed end to end (host unit, wire block,
|
||||
// client store, React hook) — the interface package's pure-type outlet
|
||||
// (`/types`, zero imports), never the package root: the root's dsh-agent →
|
||||
// dsh-session chain would drag the host `Context.sessions` merge into the
|
||||
// client program (one program must not hold both sides). No second
|
||||
// client-side "views" table (user ruling, RFC Alternatives).
|
||||
export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/**
|
||||
* The fifth framework hook seat (session-projection RFC): key-addressed
|
||||
* projection reader delivered through the standard kit. `undefined` uniformly
|
||||
* means capability absent — host unit unmounted, or no baseline/frame has
|
||||
* carried the key yet. The selector overload mirrors useSession (per-key uSES
|
||||
* binding; reference stability holds because a key's value reference changes
|
||||
* only when a frame or baseline lands).
|
||||
*/
|
||||
export type UseProjection = {
|
||||
<K extends Extract<keyof SessionProjectionMap, string>>(key: K): SessionProjectionMap[K] | undefined
|
||||
<K extends Extract<keyof SessionProjectionMap, string>, S>(
|
||||
key: K,
|
||||
selector: (value: SessionProjectionMap[K] | undefined) => S,
|
||||
eq?: (a: S, b: S) => boolean,
|
||||
): S
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail-page projections baseline — structurally identical to the wire's
|
||||
* `SessionProjectionsBlock` (apiproxy api layer), restated here so the
|
||||
* React-free store depends only on the type table, not the wire package's
|
||||
* response vocabulary.
|
||||
*/
|
||||
export interface ProjectionsBaseline {
|
||||
/** The consistent-cut seq (equals the window tail seq by construction). */
|
||||
asOfSeq: number
|
||||
/** Whole current values by key; a registered key absent here means the capability is absent. */
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/** One key's row: the latest finished value and the seq it is consistent with. */
|
||||
interface Row {
|
||||
value: unknown
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Per-key notification channel: the bare face plus its batching notifier. */
|
||||
interface Channel {
|
||||
face: ObservableSnapshot<unknown>
|
||||
notifier: Notifier
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's projection values. Framework semantics, uniform across every
|
||||
* key: a baseline seeds rows at its cut, a push frame updates one row, and in
|
||||
* both paths a lower-or-equal seq loses — a replayed frame cannot regress a
|
||||
* value, a stale baseline cannot overwrite a newer frame. A key the store has
|
||||
* never seen reads `undefined` (capability absent). Faces are identity-stable
|
||||
* per key (create-on-demand, cached) so the React side binds each exactly
|
||||
* once; the store-level channel (`subscribeAny`) serves coarse consumers (the
|
||||
* manager's list projection reads the `title` key).
|
||||
*/
|
||||
export class ProjectionValueStore {
|
||||
private readonly rows = new Map<string, Row>()
|
||||
private readonly channels = new Map<string, Channel>()
|
||||
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
|
||||
private readonly anyNotifier = new Notifier(() => {})
|
||||
|
||||
/**
|
||||
* Key-addressed bare observable face (the useProjection resolution path).
|
||||
* Always defined — absence is an `undefined` snapshot, never a missing
|
||||
* face, so a component may subscribe before the key ever carries a value.
|
||||
* @param key - projection key.
|
||||
* @returns the identity-stable face for this key.
|
||||
*/
|
||||
faceOf(key: string): ObservableSnapshot<unknown> {
|
||||
return this.channel(key).face
|
||||
}
|
||||
|
||||
/**
|
||||
* Current whole value for a key (erased framework read; typed reads go
|
||||
* through `useProjection`'s map lookup).
|
||||
* @param key - projection key.
|
||||
* @returns the value, or undefined while the key is absent.
|
||||
*/
|
||||
get(key: string): unknown {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any-key changes (microtask-batched) — the manager's list
|
||||
* rebuild channel.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribeAny(listener: () => void): () => void {
|
||||
return this.anyNotifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one finished value (the `session/projection` push-frame path).
|
||||
* @param key - projection key.
|
||||
* @param value - whole value computed by the host unit.
|
||||
* @param seq - the unit's watermark at emission.
|
||||
*/
|
||||
apply(key: string, value: unknown, seq: number): void {
|
||||
const row = this.rows.get(key)
|
||||
if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop
|
||||
this.rows.set(key, { value, seq })
|
||||
this.changed(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed from a history tail page's projections block: every carried key
|
||||
* lands under the same seq rule as frames; a key the block omits is
|
||||
* capability-absent as of the cut — its row clears unless a newer frame
|
||||
* already superseded the cut (a stale baseline can neither overwrite nor
|
||||
* clear newer values).
|
||||
* @param baseline - the response's projections block.
|
||||
*/
|
||||
seed(baseline: ProjectionsBaseline): void {
|
||||
// Erased walk: the framework crosses the open key space; per-key typing
|
||||
// is re-established at the consumer (useProjection's map lookup).
|
||||
const values = baseline.values as Record<string, unknown>
|
||||
for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq)
|
||||
for (const [key, row] of this.rows) {
|
||||
if (Object.hasOwn(values, key)) continue
|
||||
if (row.seq > baseline.asOfSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`):
|
||||
* a row claiming knowledge beyond the host's own durable baseline rode
|
||||
* state a restart lost — under last-wins it would wrongly outrank the
|
||||
* host's recomputed (lower-seq) values forever. Durable replay and the next
|
||||
* baseline re-seed whatever truly survived (the title-snapshot precedent,
|
||||
* generalized).
|
||||
* @param lastSeq - the subscribed frame's durable baseline seq.
|
||||
*/
|
||||
truncate(lastSeq: number): void {
|
||||
for (const [key, row] of this.rows) {
|
||||
if (row.seq <= lastSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
private changed(key: string): void {
|
||||
this.channels.get(key)?.notifier.markDirty()
|
||||
this.anyNotifier.markDirty()
|
||||
}
|
||||
|
||||
private channel(key: string): Channel {
|
||||
let channel = this.channels.get(key)
|
||||
if (channel === undefined) {
|
||||
// The notifier only batches (no snapshot cache to rebuild: faces read rows directly).
|
||||
const notifier = new Notifier(() => {})
|
||||
channel = {
|
||||
notifier,
|
||||
face: {
|
||||
getSnapshot: () => this.rows.get(key)?.value,
|
||||
subscribe: listener => notifier.subscribe(listener),
|
||||
},
|
||||
}
|
||||
this.channels.set(key, channel)
|
||||
}
|
||||
return channel
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,13 @@ export class SessionsService {
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
/**
|
||||
* Atomic current-session provide projection: selection changes and
|
||||
* provider-roster changes publish through this one source (the renderer
|
||||
* host's `sessions.provide` feed), so a roster change under a stable
|
||||
* current id republishes the bundle instead of stranding mounted entries.
|
||||
*/
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
@@ -167,6 +174,10 @@ export class SessionsService {
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
|
||||
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
|
||||
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
|
||||
private readonly currentProvideInfoListeners = new Set<() => void>()
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
@@ -198,7 +209,11 @@ export class SessionsService {
|
||||
// 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 current-provide projection follows the same current writes.
|
||||
this.list.subscribe(() => {
|
||||
this.followCurrent()
|
||||
this.updateCurrentProvideInfo()
|
||||
})
|
||||
// The runtime's own contribution comes first: useSession rides the same
|
||||
// provide channel every plugin uses (no renderer special case).
|
||||
this.providers.push({
|
||||
@@ -206,6 +221,14 @@ export class SessionsService {
|
||||
resolve: binding => ({ hooks: { session: binding.session } }),
|
||||
})
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
this.currentProvideInfoSnapshot = this.maybeInfo
|
||||
this.currentProvideInfo = {
|
||||
getSnapshot: () => this.currentProvideInfoSnapshot,
|
||||
subscribe: (fn) => {
|
||||
this.currentProvideInfoListeners.add(fn)
|
||||
return () => { this.currentProvideInfoListeners.delete(fn) }
|
||||
},
|
||||
}
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
@@ -238,6 +261,30 @@ export class SessionsService {
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
this.updateCurrentProvideInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the current selection's provide bundle and publish it when it
|
||||
* changed. Bundles are identity-stable per (scope, roster)
|
||||
* materialization, so an identity compare is exact; synchronous notify —
|
||||
* both call sites (list.subscribe, provide()) already sit behind their own
|
||||
* batching or registration edges.
|
||||
*/
|
||||
private updateCurrentProvideInfo(): void {
|
||||
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
|
||||
if (next === this.currentProvideInfoSnapshot) return
|
||||
this.currentProvideInfoSnapshot = next
|
||||
for (const fn of [...this.currentProvideInfoListeners]) {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
// Contain subscriber failures: this notify runs inside the list
|
||||
// notification, where a throwing render-side subscriber would starve
|
||||
// later listeners and abort the projection pass that scheduled it.
|
||||
console.error('sessions.currentProvideInfo subscriber failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
@@ -254,7 +301,7 @@ export class SessionsService {
|
||||
props[name] = undefined
|
||||
}
|
||||
}
|
||||
return { sessionId: undefined, hooks, props }
|
||||
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
@@ -287,7 +334,14 @@ export class SessionsService {
|
||||
props[name] = contributedProps[name]
|
||||
}
|
||||
}
|
||||
return { sessionId: binding.sessionId, hooks, props }
|
||||
return {
|
||||
sessionId: binding.sessionId,
|
||||
hooks,
|
||||
props,
|
||||
// The useProjection seat: key-addressed bare value faces off the
|
||||
// session's projection store (open key space — never a static roster member).
|
||||
projections: { faceOf: key => binding.session.projections.faceOf(key) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,25 +458,21 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Resolve one session's render-layer standard-props bundle (ctx never
|
||||
* enters the render layer; the renderer subscribes to
|
||||
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
|
||||
* no staging, no window side effects (StrictMode double-invokes and
|
||||
* concurrent discarded passes must stay free).
|
||||
*/
|
||||
provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
private provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
return this.resolve(id as SessionId)?.provideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current-session-optional standard kit. Unknown or absent ids
|
||||
* return the static no-session projection rather than removing hook props.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns a definite or no-session provide bundle.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
@@ -20,6 +20,8 @@ import { PendingWait } from './pending.ts'
|
||||
import { FoldAdapter } from './fold-adapter.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
@@ -35,6 +37,12 @@ export interface SessionOptions {
|
||||
* (hidden, still reusable by connectWorkspace).
|
||||
*/
|
||||
onEngaged?(session: Session): void
|
||||
/**
|
||||
* Manager-owned projection value store to adopt (frames route through the
|
||||
* manager and values outlive instantiation); omitted, the Session owns a
|
||||
* private store (bare object-layer construction).
|
||||
*/
|
||||
projections?: ProjectionValueStore
|
||||
}
|
||||
|
||||
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||
@@ -99,9 +107,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Current whole-list todo/write projection: each tail history response replaces it (an omitted
|
||||
* field is the authoritative empty list) and every live write overwrites it. */
|
||||
private todos: readonly TodoItem[] = []
|
||||
/** `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[]>()
|
||||
@@ -126,6 +131,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
|
||||
private subscribedLastSeq: number | null = null
|
||||
|
||||
/**
|
||||
* Per-session projection value store (session-projection RFC, push model):
|
||||
* finished whole values computed on the host, seeded by the tail page's
|
||||
* projections block and updated by `session/projection` frames under the
|
||||
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
|
||||
* (the useProjection resolution face); the conversation snapshot never
|
||||
* carries projection values, and no client-side domain folding exists.
|
||||
* Manager-owned when constructed through SessionManager (frames route and
|
||||
* the store outlives instantiation, the title-snapshot precedent); a bare
|
||||
* construction gets a private store.
|
||||
*/
|
||||
readonly projections: ProjectionValueStore
|
||||
|
||||
private snapshotCache: ConversationSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
@@ -149,6 +167,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private readonly api: IApiClient,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -341,13 +360,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return
|
||||
}
|
||||
case 'session/queued': {
|
||||
const message = frame.message
|
||||
// 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}`
|
||||
const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}`
|
||||
this.queued.push({
|
||||
row: { key, preview: queuePreviewOf(frame.content) },
|
||||
row: { key, preview: queuePreviewOf(message.content) },
|
||||
steering: frame.steering,
|
||||
sourceJson: JSON.stringify(frame.source),
|
||||
sourceJson: JSON.stringify(message.source),
|
||||
})
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
@@ -482,13 +502,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -505,22 +525,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
|
||||
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
|
||||
* (doOpen flips it after install), so recursing would push every buffered event straight
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void {
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
|
||||
* A carried projections block seeds the value store (higher seq wins, so a stale
|
||||
* baseline cannot overwrite a newer push frame); the window events themselves are
|
||||
* never folded — the host is the only computation site. */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
// Session-level projection from the tail page (full-log latest todo/write,
|
||||
// independent of the window); an in-window write below re-derives the same
|
||||
// value, and later live events keep overwriting it. Every caller here is a
|
||||
// tail request (no beforeSeq), which the host answers with the projection
|
||||
// or omits it only when the full log holds no todo/write — so an absent
|
||||
// field is the authoritative empty list, not a missing carrier. Assigning
|
||||
// it clears a plan the log never kept (a write lost to a host crash).
|
||||
this.todos = todos ?? []
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const item of buffered) this.appendLive(item.event, item.view)
|
||||
@@ -569,7 +585,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
@@ -588,7 +604,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
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)
|
||||
const source = JSON.stringify(event.data.message.source)
|
||||
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
|
||||
} else {
|
||||
return
|
||||
@@ -686,11 +702,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return
|
||||
}
|
||||
case 'tool/result': {
|
||||
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
|
||||
return
|
||||
}
|
||||
case 'todo/write': {
|
||||
this.todos = event.data.todos
|
||||
if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
@@ -737,10 +749,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
|
||||
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
|
||||
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text).
|
||||
* todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log
|
||||
* projection, not derivable from an arbitrary window). The window always extends to the log
|
||||
* tail, so an in-window todo/write can only overwrite it with the same latest value. */
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
|
||||
private rebuildDerivedFromWindow(): void {
|
||||
this.partial = null
|
||||
this.openCalls.clear()
|
||||
@@ -810,7 +819,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
promptError: this.promptError,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
todos: this.todos,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,13 +246,6 @@ export class SlotsService extends Service {
|
||||
if (workspaces === undefined) {
|
||||
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
|
||||
}
|
||||
// Identity-stable view: current rides the list snapshot (arbitrated), but
|
||||
// the provider consumes it as its own observable; one cached object keeps
|
||||
// the renderer's per-source hook cache stable.
|
||||
const current = {
|
||||
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
|
||||
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
|
||||
}
|
||||
this._host = {
|
||||
subscribe: (key, fn) => this._core.subscribe(key, fn),
|
||||
getVersion: key => this._core.getVersion(key),
|
||||
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
|
||||
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
|
||||
sessions: {
|
||||
list: sessions.list,
|
||||
current,
|
||||
provideInfo: id => sessions.provideInfo(id),
|
||||
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
|
||||
provideInfo: sessions.currentProvideInfo,
|
||||
},
|
||||
workspaces: { list: workspaces.list },
|
||||
}
|
||||
|
||||
@@ -229,6 +229,17 @@ export class WorkspacesService {
|
||||
return response.result.value.path
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the Host operating system's default application.
|
||||
* @param path - absolute or host-resolvable path.
|
||||
*/
|
||||
async openPath(path: string): Promise<void> {
|
||||
const response = await this.api.host.openPath({ path })
|
||||
if (!response.result.ok) {
|
||||
throw new Error(`path open failed: ${response.result.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage, createMessage, createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
// Minimal SessionEvent builders for orchestration tests (shape mirrors what the
|
||||
// host emits; only the fields the object layer reads).
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
@@ -13,7 +14,9 @@ export const ev = {
|
||||
turnStart: (seq: number, turn: number): SessionEvent =>
|
||||
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
user: (seq: number, body: string): SessionEvent =>
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: { content: text(body), source: { kind: 'user' } } }),
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: text(body), source: { kind: 'user' },
|
||||
}) }),
|
||||
stepStart: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/start', data: { turn, step } }),
|
||||
chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent =>
|
||||
@@ -21,11 +24,33 @@ export const ev = {
|
||||
chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
|
||||
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
|
||||
assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(body), provenance: { provider: 'fake', model: 'fk-1' } } }),
|
||||
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: {
|
||||
turn, step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: text(body),
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'fake', model: 'fk-1' },
|
||||
},
|
||||
}),
|
||||
} }),
|
||||
toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent =>
|
||||
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 } }),
|
||||
at(seq, {
|
||||
type: 'tool/result',
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
turn,
|
||||
step,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId(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',
|
||||
@@ -40,8 +65,10 @@ export const ev = {
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
|
||||
at(seq, { type: 'todo/write', data: { todos } }),
|
||||
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
||||
}
|
||||
|
||||
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -63,7 +64,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[] }>> =
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
@@ -84,6 +85,8 @@ export class FakeApiClient implements IApiClient {
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
@@ -119,6 +122,7 @@ export class FakeApiClient implements IApiClient {
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
|
||||
createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
|
||||
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
@@ -146,10 +150,12 @@ export class FakeApiClient implements IApiClient {
|
||||
// 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: [] }))
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
|
||||
= () => 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)),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
/**
|
||||
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
|
||||
* windows, incremental append with node-cache identity, six-variant
|
||||
@@ -39,8 +40,16 @@ describe('FoldAdapter', () => {
|
||||
const events = [
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
|
||||
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
} }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
|
||||
}) }),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(5, 0, 'c1', '结果'),
|
||||
]
|
||||
@@ -76,7 +85,17 @@ describe('FoldAdapter', () => {
|
||||
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
|
||||
const window = [
|
||||
ev.user(10, '正常'),
|
||||
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
|
||||
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
|
||||
turn: 0, step: 0,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '坏 op' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'x', model: 'y' },
|
||||
},
|
||||
}),
|
||||
} }),
|
||||
]
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
@@ -98,7 +117,15 @@ describe('FoldAdapter', () => {
|
||||
it('materializes a tool-result error field when present', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
|
||||
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
|
||||
turn: 0, step: 0,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c1'),
|
||||
content: [],
|
||||
isError: true,
|
||||
}),
|
||||
error: { name: 'Boom', code: 'boom' },
|
||||
} }),
|
||||
], 0)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
|
||||
})
|
||||
@@ -142,4 +169,83 @@ describe('FoldAdapter', () => {
|
||||
const node = adapter.nodes().nodes[0]
|
||||
expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
|
||||
})
|
||||
|
||||
describe('command lifecycle nodes', () => {
|
||||
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '先说话'),
|
||||
ev.commandRun(1, 'cmd-1', 'plan'),
|
||||
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
|
||||
ev.assistant(3, 0, '然后回答'),
|
||||
], 0)
|
||||
const { nodes } = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
|
||||
expect(nodes[1]).toMatchObject({
|
||||
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
|
||||
outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a run with no done as still executing (outcome null)', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({
|
||||
kind: 'command', name: 'goal', args: ' ship it', outcome: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
|
||||
expect(adapter.nodes().nodes[0]).toMatchObject({
|
||||
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
|
||||
outcome: { kind: 'error', text: '失败了' },
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a live-appended done in place, keeping the node at the run seq', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
|
||||
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
|
||||
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
|
||||
expect(running).toMatchObject({ outcome: null })
|
||||
adapter.append(ev.commandDone(7, 'cmd-4'))
|
||||
const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
|
||||
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
|
||||
// Settlement replaced the node object rather than mutating the published one.
|
||||
expect(settled).not.toBe(running)
|
||||
})
|
||||
|
||||
it('tails command nodes whose seq is past every surface node', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
|
||||
expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
|
||||
})
|
||||
|
||||
it('command nodes survive the degraded linear-scan branch', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([
|
||||
ev.commandRun(0, 'cmd-5', 'plan'),
|
||||
ev.commandDone(1, 'cmd-5'),
|
||||
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
|
||||
turn: 0,
|
||||
step: 0,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '坏 op' }],
|
||||
source: { kind: 'model', provider: 'x', model: 'y' },
|
||||
}),
|
||||
} }),
|
||||
], 0)
|
||||
const { nodes, degraded } = adapter.nodes()
|
||||
expect(degraded).toBe(true)
|
||||
expect(nodes.some(n => n.kind === 'command')).toBe(true)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,21 +133,18 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
||||
})
|
||||
|
||||
it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => {
|
||||
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-new' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-stale' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-equal' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 },
|
||||
})
|
||||
const titleFrame = (rpcId: string, title: string, seq: number) => {
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: rpcId as never,
|
||||
payload: { type: 'session/projection', sessionId: S1, key: 'title', value: title, seq } as never,
|
||||
})
|
||||
}
|
||||
titleFrame('title-new', 'Newest', 4)
|
||||
titleFrame('title-stale', 'Stale', 3)
|
||||
titleFrame('title-equal', 'Equal', 4)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
|
||||
}))
|
||||
@@ -155,7 +152,7 @@ describe('list lifecycle', () => {
|
||||
|
||||
const titled = manager.getListSnapshot()
|
||||
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
|
||||
expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 })
|
||||
expect(titled.items[0]?.title).toBe('Newest')
|
||||
expect(titled.items[1]?.title).toBeUndefined()
|
||||
|
||||
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
@@ -163,34 +160,27 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-unflushed' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 },
|
||||
})
|
||||
const frame = (rpcId: string, payload: object) => {
|
||||
manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never })
|
||||
}
|
||||
frame('title-unflushed', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-recovered' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
// The durable baseline says the host only knows up to seq 2: the phantom
|
||||
// row rode lost state and must drop, or last-wins pins it forever.
|
||||
frame('subscribed-recovered', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'title-durable' as never,
|
||||
payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
frame('title-durable', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 })
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'subscribed-current' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 })
|
||||
// A baseline at or past the row's seq keeps it (nothing phantom to drop).
|
||||
frame('subscribed-current', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
187
packages/client/runtime/tests/projection-store.spec.ts
Normal file
187
packages/client/runtime/tests/projection-store.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Projection value store (session-projection RFC, push model): the single
|
||||
* higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
|
||||
* newer push frame; a replayed frame cannot regress), capability absence as
|
||||
* undefined, generation truncation, and the Session/manager wiring (tail-page
|
||||
* seeding, session/projection frame routing pre- and post-instantiation, the
|
||||
* list rows' title projection).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
import { entries, plainTurn } from './event-script.ts'
|
||||
|
||||
// Test-domain keys merged into the projection map (the interface package's
|
||||
// pure-type outlet), the same way domain host plugins merge theirs.
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
'test/marks': { marks: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
|
||||
describe('ProjectionValueStore semantics', () => {
|
||||
it('reads undefined until a value lands (capability absence)', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.get('test/marks')).toBeUndefined()
|
||||
expect(store.faceOf('test/marks').getSnapshot()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies frames last-wins by seq: replayed and stale frames drop', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
store.apply('test/marks', { marks: ['a'] }, 5)
|
||||
store.apply('test/marks', { marks: ['a', 'b'] }, 9)
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
|
||||
store.apply('test/marks', { marks: ['stale'] }, 5)
|
||||
store.apply('test/marks', { marks: ['equal'] }, 9)
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
|
||||
})
|
||||
|
||||
it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
store.apply('test/marks', { marks: ['frame-20'] }, 20)
|
||||
// Stale cut: carried key loses to the newer frame; omitted key survives.
|
||||
store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } })
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
|
||||
store.seed({ asOfSeq: 15, values: {} })
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
|
||||
// Fresh cut: carried key reseeds…
|
||||
store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } })
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
|
||||
// …and an omitting fresh cut clears (capability absent as of the cut).
|
||||
store.seed({ asOfSeq: 40, values: {} })
|
||||
expect(store.get('test/marks')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('truncate drops rows past the durable baseline and keeps the rest', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
store.apply('test/marks', { marks: ['durable'] }, 5)
|
||||
store.apply('other', 'phantom', 50)
|
||||
store.truncate(10)
|
||||
expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
|
||||
expect(store.get('other')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notifies the key face on change (batched) and not on dropped applications', async () => {
|
||||
const store = new ProjectionValueStore()
|
||||
let keyTicks = 0
|
||||
let anyTicks = 0
|
||||
store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
|
||||
store.subscribeAny(() => { anyTicks += 1 })
|
||||
store.apply('test/marks', { marks: ['a'] }, 5)
|
||||
await Promise.resolve()
|
||||
expect(keyTicks).toBe(1)
|
||||
expect(anyTicks).toBe(1)
|
||||
store.apply('test/marks', { marks: ['replay'] }, 3)
|
||||
await Promise.resolve()
|
||||
expect(keyTicks).toBe(1)
|
||||
expect(anyTicks).toBe(1)
|
||||
})
|
||||
|
||||
it('faces are identity-stable per key (the React binding cache premise)', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session tail-page seeding', () => {
|
||||
it('seeds the store from a history response carrying a projections block', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
|
||||
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
|
||||
} as never))
|
||||
await session.open()
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
|
||||
})
|
||||
|
||||
it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
||||
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
|
||||
} as never))
|
||||
await session.open()
|
||||
session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9)
|
||||
await session.resync()
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
|
||||
})
|
||||
|
||||
it('treats a blockless response as no reset: pushed values survive', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api)
|
||||
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
||||
await session.open()
|
||||
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
|
||||
await session.resync()
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('manager frame routing', () => {
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
|
||||
})
|
||||
const session = manager.get(sid('s1'))
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
|
||||
// Frames after instantiation land in the same store.
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p2' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never,
|
||||
})
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
|
||||
})
|
||||
|
||||
it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 't1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
|
||||
// The durable baseline says the host only knows up to seq 2: the row rode
|
||||
// lost state and must drop (the un-flushed title precedent).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'sub' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 't1' as never,
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never,
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'rm' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
|
||||
})
|
||||
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@
|
||||
* pre-instantiation buffering, and snapshot reference stability.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
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'
|
||||
@@ -19,8 +20,12 @@ 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,
|
||||
type: 'session/queued',
|
||||
sessionId: SID,
|
||||
message: createUserMessage({
|
||||
content: text(body),
|
||||
source: { kind: 'user', rpcId: rid(rpcId) } as never,
|
||||
}),
|
||||
steering,
|
||||
}
|
||||
}
|
||||
@@ -40,9 +45,12 @@ describe('queue intake', () => {
|
||||
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' },
|
||||
type: 'session/queued',
|
||||
sessionId: SID,
|
||||
message: createUserMessage({
|
||||
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]' }])
|
||||
@@ -93,14 +101,26 @@ describe('queue retirement (host queuedMirror rules)', () => {
|
||||
const foreignSteering = {
|
||||
seq: 0, time: 1,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } },
|
||||
data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: text('loop'),
|
||||
source: { kind: 'plugin', plugin: 'loop' },
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e4'), { 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') } },
|
||||
data: {
|
||||
turn: 0,
|
||||
message: createUserMessage({
|
||||
content: text('插话'),
|
||||
source: { kind: 'user', rpcId: rid('p-2') },
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
|
||||
@@ -154,7 +174,13 @@ describe('queue reconnect semantics', () => {
|
||||
const committed = {
|
||||
seq: 6, time: 2,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } },
|
||||
data: {
|
||||
turn: 1,
|
||||
message: createUserMessage({
|
||||
content: text('重连插话'),
|
||||
source: { kind: 'user', rpcId: rid('p-steer') },
|
||||
}),
|
||||
},
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
|
||||
@@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
|
||||
return { api, session: new Session(SID, api) }
|
||||
}
|
||||
|
||||
function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) {
|
||||
function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } }))
|
||||
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
@@ -104,6 +104,28 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().nodes).toEqual(before.nodes)
|
||||
})
|
||||
|
||||
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
|
||||
// Live path: run mints an executing node, done settles it in the flow.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.commandRun(6, 'cmd-live', 'plan'))
|
||||
let command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
|
||||
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
|
||||
command = session.getSnapshot().nodes.at(-1)
|
||||
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
|
||||
|
||||
// Replay path (refresh): the same pair inside the history window folds identically.
|
||||
const replayed = await opened([
|
||||
...plainTurn(0, 0, 'a', 'b'),
|
||||
ev.commandRun(6, 'cmd-live', 'plan'),
|
||||
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
|
||||
])
|
||||
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -158,42 +180,6 @@ describe('live event path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => {
|
||||
const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }]
|
||||
const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }]
|
||||
const { session } = await opened()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.todoWrite(6, listA))
|
||||
expect(session.getSnapshot().todos).toEqual(listA)
|
||||
feed(ev.todoWrite(7, listB))
|
||||
expect(session.getSnapshot().todos).toEqual(listB)
|
||||
// Window replay converges on the same last snapshot (history contains both writes).
|
||||
const replayed = makeSession()
|
||||
replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)])
|
||||
await replayed.session.open()
|
||||
expect(replayed.session.getSnapshot().todos).toEqual(listB)
|
||||
})
|
||||
|
||||
it('seeds todos from the tail page projection when the last write precedes the window', async () => {
|
||||
const list = [{ content: '窗口外的计划', status: 'in_progress' as const }]
|
||||
// Cold open: the page window carries NO todo/write; the projection rides the response.
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list)
|
||||
await session.open()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// Paging an older window in must not clear the session-level projection.
|
||||
api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false)
|
||||
await session.loadOlder()
|
||||
expect(session.getSnapshot().todos).toEqual(list)
|
||||
// A later live write still overrides the seeded projection.
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }])
|
||||
})
|
||||
|
||||
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
|
||||
@@ -207,37 +193,6 @@ describe('live event path', () => {
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
|
||||
})
|
||||
|
||||
it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
// The missed range contained a todo/write that the repulled page no longer
|
||||
// covers; the response's session-level projection is the only carrier.
|
||||
const current = [{ content: '断线期间写的', status: 'in_progress' as const }]
|
||||
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current)
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') })
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.history').length).toBe(2)
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(session.getSnapshot().todos).toEqual(current)
|
||||
})
|
||||
|
||||
it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => {
|
||||
// Live write lands, then the host crashes before persisting it: the
|
||||
// authoritative log holds no todo/write, so the resync tail response
|
||||
// carries no projection — an omitted field on a tail request is the empty
|
||||
// list, not a missing carrier, and the rolled-back plan must disappear.
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b'))
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]),
|
||||
})
|
||||
expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }])
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().todos).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('paging', () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('list store projection', () => {
|
||||
const b = bench()
|
||||
b.svc.handleMuxEnvelope({
|
||||
rpcId: 'title' as never,
|
||||
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
|
||||
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2 } as never,
|
||||
})
|
||||
await feedList(b, [
|
||||
{ id: 's1', cwd: '/home/u/proj-a/' },
|
||||
@@ -79,7 +79,8 @@ 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.provideInfo('s1')?.hooks['session'])
|
||||
b.svc.open(sid('s1'))
|
||||
expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
|
||||
expect(b.svc.binding(sid('s1'))).toBe(binding)
|
||||
expect(binding?.ctx).toBe(scoped)
|
||||
})
|
||||
@@ -183,24 +184,82 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
|
||||
})
|
||||
|
||||
describe('cell (render-layer session kit)', () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const info = b.svc.provideInfo('s1')
|
||||
expect(info).toBeDefined()
|
||||
expect(info?.sessionId).toBe('s1')
|
||||
b.svc.open(sid('s1'))
|
||||
const info = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(info.sessionId).toBe('s1')
|
||||
// The bundle carries bare observables; hook binding happens in React.
|
||||
expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(b.svc.provideInfo('s1')).toBe(info)
|
||||
expect(b.svc.provideInfo('ghost')).toBeUndefined()
|
||||
expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
// Re-staging the same id republishes nothing: identity holds.
|
||||
b.svc.open(sid('s1'))
|
||||
expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
|
||||
})
|
||||
|
||||
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
const absent = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(absent.sessionId).toBeUndefined()
|
||||
expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
|
||||
const notified = vi.fn()
|
||||
b.svc.currentProvideInfo.subscribe(notified)
|
||||
b.svc.open(sid('s1'))
|
||||
const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(s1Bundle.sessionId).toBe('s1')
|
||||
expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(notified).toHaveBeenCalledTimes(1)
|
||||
b.svc.open(sid('s2'))
|
||||
const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(s2Bundle.sessionId).toBe('s2')
|
||||
expect(s2Bundle).not.toBe(s1Bundle)
|
||||
expect(notified).toHaveBeenCalledTimes(2)
|
||||
b.svc.clear()
|
||||
await Promise.resolve() // clearSelection projects through the manager notifier
|
||||
expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a provider roster change under a stable current id republishes the bundle', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
const before = b.svc.currentProvideInfo.getSnapshot()
|
||||
const notified = vi.fn()
|
||||
b.svc.currentProvideInfo.subscribe(notified)
|
||||
const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
|
||||
const dispose = b.svc.provide({
|
||||
hooks: ['extra'],
|
||||
props: ['marker'],
|
||||
resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
|
||||
})
|
||||
const added = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(added).not.toBe(before)
|
||||
expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
|
||||
expect(added.hooks['extra']).toBe(source)
|
||||
expect(notified).toHaveBeenCalledTimes(1)
|
||||
dispose()
|
||||
const removed = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(removed).not.toBe(added)
|
||||
expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
|
||||
expect(notified).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const notified = vi.fn()
|
||||
const off = b.svc.currentProvideInfo.subscribe(notified)
|
||||
off()
|
||||
b.svc.open(sid('s1'))
|
||||
expect(notified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binding() is 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.provideInfo('s2') // resolution only — must NOT move the stage
|
||||
b.svc.binding(sid('s2'))
|
||||
b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
})
|
||||
@@ -211,7 +270,6 @@ 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.provideInfo('s1')
|
||||
b.svc.binding(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(0)
|
||||
b.svc.open(sid('s1'))
|
||||
|
||||
@@ -97,18 +97,13 @@ function fakeWorkspaces() {
|
||||
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
|
||||
}
|
||||
|
||||
/** Minimal sessions face for the host seam (list observable + provide bundle). */
|
||||
/** Minimal sessions face for the host seam (list observable + current provide projection). */
|
||||
function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
|
||||
return {
|
||||
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||
provideInfo: (id: string) => (id === 'known'
|
||||
? {
|
||||
sessionId: id,
|
||||
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
|
||||
props: {},
|
||||
}
|
||||
: undefined),
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,13 +227,11 @@ describe('host face', () => {
|
||||
expect(host.entriesOf('t.host')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
|
||||
it('exposes the session list and the atomic current provide projection', 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.provideInfo('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
|
||||
expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
|
||||
})
|
||||
|
||||
it('exposes the independent Workspace list source', async () => {
|
||||
|
||||
@@ -268,6 +268,17 @@ describe('WorkspacesService', () => {
|
||||
await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
|
||||
})
|
||||
|
||||
it('opens a filesystem path through the host without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
|
||||
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
|
||||
api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
|
||||
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
|
||||
})
|
||||
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user