Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.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: 645398e5efb429a82f612f2e6540924afd46cfd4
|
||||
README.zh.md: 572fe07128294bedae05a967ea8eee23532e4feb
|
||||
README.md: 1dc67b076870f0037fdc19dba93f959bd02eca92
|
||||
README.zh.md: 891160de8c2858e9951216274dad44c14cbc526c
|
||||
|
||||
@@ -10,8 +10,12 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
|
||||
|
||||
`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store.
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
|
||||
|
||||
## New Session and the blank mirror
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
@@ -20,14 +24,28 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
|
||||
|
||||
## The human transcript
|
||||
|
||||
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
|
||||
|
||||
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
|
||||
|
||||
## Request inspection
|
||||
|
||||
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
|
||||
|
||||
## Code Mode sub-dispatch index
|
||||
|
||||
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
|
||||
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
|
||||
|
||||
## Session title projection
|
||||
|
||||
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
|
||||
|
||||
## Model retry projection
|
||||
|
||||
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted assistant node beside the terminal error.
|
||||
|
||||
## Session forking
|
||||
|
||||
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
|
||||
|
||||
@@ -10,8 +10,12 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
|
||||
|
||||
`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
@@ -20,14 +24,28 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。
|
||||
|
||||
## 面向人的 transcript(文本记录)
|
||||
|
||||
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
|
||||
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
|
||||
## 请求检查
|
||||
|
||||
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
|
||||
|
||||
## Code Mode 子调用索引
|
||||
|
||||
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
|
||||
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
|
||||
|
||||
## Session 标题投影
|
||||
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
|
||||
|
||||
## 模型重试投影
|
||||
|
||||
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
|
||||
|
||||
## 会话 fork
|
||||
|
||||
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
|
||||
@@ -42,10 +60,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
更改目标可能改变提供方侧的缓存复用,或使其失效;该包(package)本身不会改变提示词前缀。
|
||||
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose(资源释放) → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘(postmortem)所记录的问题。
|
||||
|
||||
@@ -32,10 +32,12 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "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-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
@@ -49,6 +51,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -46,7 +46,8 @@ export interface ISession {
|
||||
*/
|
||||
updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Cancel the running turn.
|
||||
* Cancel the running turn. Pending queued work remains and resumes in FIFO
|
||||
* order after the Host reaches cancellation quiescence.
|
||||
* @returns acceptance, or the business error.
|
||||
*/
|
||||
cancel(): Promise<RpcResult<{ accepted: true }>>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
import type {
|
||||
SessionBinding, SessionListState, SessionProvideDescriptor,
|
||||
} from '../sessions/service.ts'
|
||||
@@ -22,6 +23,12 @@ export interface ISessions {
|
||||
readonly list: ObservableSnapshot<SessionListState>
|
||||
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
/**
|
||||
* The `session.search` result bound the wire schema fixes, exposed to
|
||||
* presentation as injected data. Not per-connection state: every transport
|
||||
* (fixture included) reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit: number
|
||||
/**
|
||||
* Select a session as current.
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
@@ -29,6 +36,17 @@ export interface ISessions {
|
||||
open(id: SessionId): void
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
* Search the Host's visible message-content index. Results stay
|
||||
* request-local; the list snapshot remains the metadata authority.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search.
|
||||
* @returns bounded results, or a business/transport error.
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source; on resolution
|
||||
* the child is in the list store and `open()` can target it.
|
||||
|
||||
@@ -76,4 +76,11 @@ export interface IWorkspaces {
|
||||
* @returns the updated Workspace view.
|
||||
*/
|
||||
insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
|
||||
/**
|
||||
* Archive a session into the registry-global set (hidden from grouping
|
||||
* surfaces; session log and accounting slot remain). Archiving the current
|
||||
* session clears the selection into the New Session view state.
|
||||
* @param sessionId - session to archive.
|
||||
*/
|
||||
archiveSession(sessionId: SessionId): Promise<void>
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase } from './sessions/manager.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type {
|
||||
@@ -44,9 +44,10 @@ export type {
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
|
||||
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
|
||||
RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
MessageId, RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
@@ -121,6 +122,32 @@ export interface ContextMessageNode {
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** Durable notice that a closed failed step is waiting for a model-request retry. */
|
||||
export type ModelRetryNode = LlmRetryEventData & {
|
||||
kind: 'model-retry'
|
||||
seq: number
|
||||
/** Unix epoch ms from the llm/retry session event. */
|
||||
time: number
|
||||
/**
|
||||
* Client-derived lifecycle: scheduled until a retry turn starts, started
|
||||
* once it does, or cancelled when the failed turn aborts first.
|
||||
*/
|
||||
retryState: 'scheduled' | 'started' | 'cancelled'
|
||||
}
|
||||
|
||||
/** Durable terminal failure for a turn that has no scheduled retry. */
|
||||
export interface TurnErrorNode {
|
||||
kind: 'turn-error'
|
||||
/** Seq of the owning turn/end event. */
|
||||
seq: number
|
||||
/** Unix epoch ms from the turn/end event. */
|
||||
time: number
|
||||
turn: number
|
||||
step: number
|
||||
message: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
/** A tool result paired (when in-window) with its call head. */
|
||||
export interface ToolResultNode {
|
||||
kind: 'tool-result'
|
||||
@@ -142,7 +169,32 @@ export interface ToolResultNode {
|
||||
resultView: ToolResultView | null
|
||||
}
|
||||
|
||||
/** Fallback for surface events this UI version does not know. */
|
||||
/**
|
||||
* One landed compaction, marked at the checkpoint's own log position. The
|
||||
* conversation it shadowed on the model surface stays in the transcript above
|
||||
* it: the marker reports where the model stopped seeing that history, it does
|
||||
* not replace it. The framed checkpoint payload is an instruction envelope
|
||||
* written for the model and never renders.
|
||||
*/
|
||||
export interface CompactionSummaryNode {
|
||||
kind: 'compaction'
|
||||
/** Seq of the replacement `user/message` that landed the checkpoint. */
|
||||
seq: number
|
||||
/** Unix epoch ms of the checkpoint event. */
|
||||
time: number
|
||||
/** Summary text from the checkpoint's `compact/summary` provenance; null when
|
||||
* the window cut left that provenance outside (the marker is then not expandable). */
|
||||
summary: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for surface events this UI version does not know: the documented
|
||||
* default arm of `SessionEventMap`, which is merge-extensible, so the
|
||||
* projection's switch cannot end in `assertNever`. No event produces this node
|
||||
* today — `isAppendSurfaceEvent` admits only the four types in core's
|
||||
* `SurfaceEventType`, and each has its own arm — and it exists so widening that
|
||||
* set core-side degrades to a raw row instead of dropping the event silently.
|
||||
*/
|
||||
export interface UnknownSurfaceNode {
|
||||
kind: 'unknown'
|
||||
seq: number
|
||||
@@ -155,7 +207,7 @@ export interface UnknownSurfaceNode {
|
||||
/**
|
||||
* 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
|
||||
* Log-only events are not surface events, so the TranscriptAdapter 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
|
||||
@@ -183,8 +235,11 @@ export type ConversationNode =
|
||||
| AssistantMessageNode
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| ModelRetryNode
|
||||
| TurnErrorNode
|
||||
| ToolResultNode
|
||||
| CommandNode
|
||||
| CompactionSummaryNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
@@ -194,7 +249,7 @@ export type ConversationNode =
|
||||
* {@link RunningToolCall} (rows derive the running state from the shape,
|
||||
* exactly as for native calls) and its `tool/code-dispatch` settlement
|
||||
* replaces it in place with the {@link ToolResultNode} form. Never part of
|
||||
* the surface `nodes` flow — sub-calls live under their parent via
|
||||
* the transcript `nodes` flow — sub-calls live under their parent via
|
||||
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
|
||||
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
|
||||
* and its JSON-stringified logged arguments; `content`/`isError` are the
|
||||
@@ -265,10 +320,8 @@ export interface PromptError {
|
||||
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
|
||||
export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Surface fold product (finalized conversation nodes in surface order). */
|
||||
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||
foldDegraded: boolean
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Convert a durable failure into copy that is safe to expose in the GUI.
|
||||
* @param failure - Structured failure preserved by the session event.
|
||||
* @returns Display-safe copy for client projections.
|
||||
*/
|
||||
export function displayFailureMessage(failure: { code?: string; message: string }): string {
|
||||
// Provider AUTH messages may echo a masked or partially preserved credential.
|
||||
// Keep the raw diagnostic in the session log, but never project it into UI state.
|
||||
return failure.code === 'AUTH' ? 'API key is invalid' : failure.message
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
|
||||
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index);
|
||||
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
|
||||
// the degradation lives in one branch function in this file, zero scattered removal points).
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// Subpath export (package.json exports "./surface", alias added for this): all value imports
|
||||
// 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, isSurfaceEvent,
|
||||
} 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 { CommandNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
|
||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||
export interface CallIndexEntry {
|
||||
name: string
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms of the tool/call event. */
|
||||
time: number
|
||||
/** Wire view riding the tool/call (envelope-level; never inside the event). */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** Non-surface sentinel used to preserve paged-window sequence offsets.
|
||||
* `noop/padding` is deliberately not a real event type, so it cannot acquire
|
||||
* surface behavior; this cast is the only synthetic event entry point.
|
||||
*/
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
// Injected context (plugin/goal source) folds to a context node, not a
|
||||
// user message; only a direct human prompt is a user node.
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
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.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
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,
|
||||
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
|
||||
callTime: call?.time ?? null,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error !== undefined ? { error: event.data.error } : {}),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
}
|
||||
}
|
||||
/* v8 ignore next 2 -- defensive arm: fold output only carries the four
|
||||
surface-eligible types, and each has a case above; reachable only if core
|
||||
adds an eligible type. */
|
||||
default:
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */
|
||||
export class FoldAdapter {
|
||||
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
|
||||
private padded: SessionEvent[] = []
|
||||
private baseSeq = 0
|
||||
private surface = new SurfaceManager(this.padded)
|
||||
private nodeCache = new Map<number, ConversationNode>()
|
||||
private degraded = false
|
||||
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. */
|
||||
private rev = 0
|
||||
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
|
||||
|
||||
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
|
||||
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
|
||||
return this.callIdx
|
||||
}
|
||||
|
||||
/**
|
||||
* Window rebuild (after open/resync/page prepend): new padded array, new
|
||||
* SurfaceManager, cleared cache, rebuilt callIndex.
|
||||
* @param events - the new window contents (seq-ascending).
|
||||
* @param baseSeq - seq of the window head (sentinels pad below it).
|
||||
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
|
||||
*/
|
||||
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
|
||||
this.rev++
|
||||
this.baseSeq = baseSeq
|
||||
this.padded = []
|
||||
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
|
||||
for (const event of events) this.padded.push(event)
|
||||
this.surface = new SurfaceManager(this.padded)
|
||||
this.nodeCache.clear()
|
||||
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
|
||||
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])
|
||||
this.indexCommand(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail append (live session/event): push into the same array (incremental
|
||||
* lazy fold applies) + incremental callIndex upkeep.
|
||||
* @param event - the live event (seq = window tail + 1).
|
||||
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
|
||||
*/
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.rev++
|
||||
this.padded.push(event)
|
||||
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
|
||||
this.indexCall(event, view)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Current node array + degradation flag. Same revision -> same array
|
||||
* reference (memo boundary); node object references always come from the per-seq cache.
|
||||
* @returns the fold projection for the current window revision.
|
||||
*/
|
||||
nodes(): { nodes: ConversationNode[]; degraded: boolean } {
|
||||
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
|
||||
let seqs: readonly number[]
|
||||
if (this.degraded) {
|
||||
seqs = this.degradedSeqs()
|
||||
} else {
|
||||
try {
|
||||
seqs = this.surface.nodes
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] surface fold failed, degrading to linear scan:', error)
|
||||
this.degraded = true
|
||||
seqs = this.degradedSeqs()
|
||||
}
|
||||
}
|
||||
const out: ConversationNode[] = []
|
||||
for (const seq of seqs) {
|
||||
const cached = this.nodeCache.get(seq)
|
||||
if (cached !== undefined) {
|
||||
out.push(cached)
|
||||
continue
|
||||
}
|
||||
const event = this.padded[seq]
|
||||
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
|
||||
if (event === undefined) continue
|
||||
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
|
||||
this.nodeCache.set(seq, node)
|
||||
out.push(node)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
|
||||
private degradedSeqs(): number[] {
|
||||
const seqs: number[] = []
|
||||
for (let i = this.baseSeq; i < this.padded.length; i++) {
|
||||
const event = this.padded[i]
|
||||
if (event !== undefined && isSurfaceEligibleType(event.type)) seqs.push(event.seq)
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'tool/call') return
|
||||
this.callIdx.set(String(event.data.callId), {
|
||||
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
|
||||
time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
// No backfill into already-materialized tool-result nodes for this callId
|
||||
// (window order puts the call before its result; cannot happen on the normal path).
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -27,6 +30,12 @@ import { Session } from './session.ts'
|
||||
*/
|
||||
export type SessionListPhase = 'pending' | 'ready'
|
||||
|
||||
/** Request-local content hit returned to sidebar search consumers. */
|
||||
export interface SessionSearchResultItem {
|
||||
sessionId: SessionId
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** Immutable session-list snapshot for useSessionList. */
|
||||
export interface SessionListSnapshot {
|
||||
items: readonly SessionListEntry[]
|
||||
@@ -248,6 +257,24 @@ export class SessionManager {
|
||||
return this.listInflight
|
||||
}
|
||||
|
||||
/**
|
||||
* Search visible session message content without adding transient query
|
||||
* state to the list snapshot.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for superseded UI queries.
|
||||
* @returns the Host result or a folded transport error.
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
|
||||
try {
|
||||
return (await this.api.sessions.search({ query }, signal)).result
|
||||
} catch (error: unknown) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract session.create; on success merge into summaries immediately (no
|
||||
* wait for the next refresh). A created session is blank by definition
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
import { displayFailureMessage } from './failure-display.ts'
|
||||
|
||||
export type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
@@ -35,40 +36,55 @@ export interface RequestPromptChange {
|
||||
previous?: ConversationPromptSnapshot
|
||||
}
|
||||
|
||||
/** One provider request reconstructed from durable request lifecycle events. */
|
||||
export interface RequestView {
|
||||
/** Request category; compaction is a purpose, not a separate projection. */
|
||||
purpose: 'assistant' | 'compaction'
|
||||
/** Lifecycle fields shared by ordinary generation and compaction requests. */
|
||||
interface RequestViewBase {
|
||||
/** Sequence that opened the operation represented by this request. */
|
||||
startSeq: number
|
||||
turn: number
|
||||
/** Agent-loop step, or zero for a direct compaction request. */
|
||||
step: number
|
||||
startedAt: number
|
||||
completedAt: number | null
|
||||
status: 'running' | 'complete' | 'error'
|
||||
error?: string
|
||||
/** Effective ordinary request input, inherited until a later header changes it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Prompt change logged while preparing this request. */
|
||||
promptChange?: RequestPromptChange
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
usage?: unknown
|
||||
/** Assistant message or compaction summary sequence produced by this request. */
|
||||
resultSeq?: number
|
||||
}
|
||||
|
||||
/** One ordinary assistant generation reconstructed from durable request events. */
|
||||
interface AssistantRequestView extends RequestViewBase {
|
||||
purpose: 'assistant'
|
||||
turn: number
|
||||
/** Agent-loop step that issued this request. */
|
||||
step: number
|
||||
/** Effective ordinary request input, inherited until a later header changes it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Prompt change logged while preparing this request. */
|
||||
promptChange?: RequestPromptChange
|
||||
/** Retry ordinal scheduled after a failed ordinary request. */
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
/** One compaction provider request, either turn-owned or standalone between turns. */
|
||||
interface CompactionRequestView extends RequestViewBase {
|
||||
purpose: 'compaction'
|
||||
/** Owning turn, or `null` when manual compaction ran between turns. */
|
||||
turn: number | null
|
||||
/** Direct compaction requests do not consume an agent-loop step. */
|
||||
step: 0
|
||||
/** Compaction replacement message sequence, when one was committed. */
|
||||
replacementSeq?: number
|
||||
/** Safe compaction summary projection. */
|
||||
summary?: readonly ContentBlock[]
|
||||
/** Complete compaction provider output before the safe projection. */
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
/** Retry ordinal scheduled after a failed ordinary request. */
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
/** One provider request reconstructed from durable request lifecycle events. */
|
||||
export type RequestView = AssistantRequestView | CompactionRequestView
|
||||
|
||||
/** Immutable request-centric projection derived from one history window. */
|
||||
export interface RequestInspectionSnapshot {
|
||||
requests: readonly RequestView[]
|
||||
@@ -110,7 +126,7 @@ interface CompactionStartEvent {
|
||||
type: 'compact/start'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number }
|
||||
data: { turn: number | null }
|
||||
}
|
||||
|
||||
interface CompactionSummaryEvent {
|
||||
@@ -131,7 +147,7 @@ interface CompactionEndEvent {
|
||||
type: 'compact/end'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number; error?: string }
|
||||
data: { turn: number | null; error?: string }
|
||||
}
|
||||
|
||||
function requestKey(turn: number, step: number): string {
|
||||
@@ -228,10 +244,21 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let activeCompaction: number | undefined
|
||||
|
||||
const update = (index: number | undefined, change: Partial<RequestView>): void => {
|
||||
const updateAssistant = (
|
||||
index: number | undefined,
|
||||
change: Partial<Omit<AssistantRequestView, 'purpose'>>,
|
||||
): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request !== undefined) requests[index] = { ...request, ...change }
|
||||
if (request?.purpose === 'assistant') requests[index] = { ...request, ...change }
|
||||
}
|
||||
const updateCompaction = (
|
||||
index: number | undefined,
|
||||
change: Partial<Omit<CompactionRequestView, 'purpose'>>,
|
||||
): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request?.purpose === 'compaction') requests[index] = { ...request, ...change }
|
||||
}
|
||||
|
||||
for (const sourceEvent of events) {
|
||||
@@ -263,7 +290,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
}
|
||||
const change = promptChange(activePrompt, prompt, sourceEvent)
|
||||
activePrompt = prompt
|
||||
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
|
||||
updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
|
||||
prompt,
|
||||
requestConfig: prompt.config,
|
||||
...(change === undefined ? {} : { promptChange: change }),
|
||||
@@ -278,8 +305,11 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage),
|
||||
updateAssistant(index, {
|
||||
usage: addTokenUsage(
|
||||
request?.purpose === 'assistant' ? request.usage : undefined,
|
||||
sourceEvent.data.chunk.usage,
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -288,7 +318,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
updateAssistant(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'complete',
|
||||
resultSeq: sourceEvent.seq,
|
||||
@@ -296,7 +326,9 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
provider: sourceEvent.data.message.source.provider,
|
||||
model: sourceEvent.data.message.source.model,
|
||||
},
|
||||
...(request?.usage !== undefined || sourceEvent.data.usage === undefined
|
||||
...(request?.purpose === 'assistant'
|
||||
&& request.usage !== undefined
|
||||
|| sourceEvent.data.usage === undefined
|
||||
? {}
|
||||
: { usage: sourceEvent.data.usage }),
|
||||
})
|
||||
@@ -306,8 +338,8 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
|
||||
const index = ordinaryByStep.get(key)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
if (request?.status === 'running') {
|
||||
update(index, {
|
||||
if (request?.purpose === 'assistant' && request.status === 'running') {
|
||||
updateAssistant(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
})
|
||||
@@ -317,16 +349,34 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
}
|
||||
if ((sourceEvent.type as string) === 'llm/retry') {
|
||||
const event = sourceEvent as unknown as RetryEvent
|
||||
update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
|
||||
updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
|
||||
status: 'error',
|
||||
error: event.data.failure.message,
|
||||
error: displayFailureMessage(event.data.failure),
|
||||
retry: event.data.retry,
|
||||
maxRetries: event.data.maxRetries,
|
||||
retryDelayMs: event.data.delayMs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
|
||||
const reason = sourceEvent.data.reason
|
||||
updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
|
||||
status: 'error',
|
||||
error: displayFailureMessage('failure' in reason ? reason.failure : reason),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const type = sourceEvent.type as string
|
||||
if (type === 'session/end-seed' && activeCompaction !== undefined) {
|
||||
updateCompaction(activeCompaction, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
error: 'Compaction was interrupted before completion.',
|
||||
})
|
||||
activeCompaction = undefined
|
||||
continue
|
||||
}
|
||||
if (type === 'compact/start') {
|
||||
const event = sourceEvent as unknown as CompactionStartEvent
|
||||
activeCompaction = requests.length
|
||||
@@ -343,7 +393,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
}
|
||||
if (type === 'compact/summary' && activeCompaction !== undefined) {
|
||||
const event = sourceEvent as unknown as CompactionSummaryEvent
|
||||
update(activeCompaction, {
|
||||
updateCompaction(activeCompaction, {
|
||||
resultSeq: event.seq,
|
||||
summary: event.data.summary,
|
||||
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
|
||||
@@ -366,12 +416,12 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
&& activeCompaction !== undefined
|
||||
&& isCompactionSource(sourceEvent.data.source)
|
||||
) {
|
||||
update(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
continue
|
||||
}
|
||||
if (type !== 'compact/end' || activeCompaction === undefined) continue
|
||||
const event = sourceEvent as unknown as CompactionEndEvent
|
||||
update(activeCompaction, {
|
||||
updateCompaction(activeCompaction, {
|
||||
completedAt: event.time,
|
||||
status: event.data.error === undefined ? 'complete' : 'error',
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -26,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
|
||||
import type { ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
|
||||
import { SessionProvideChannel } from './provide.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -189,6 +194,13 @@ export interface SessionProvideDescriptor {
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService implements ISessions {
|
||||
/**
|
||||
* The wire schema's own result bound, re-exposed for presentation plugins as
|
||||
* injected data. Not per-connection state: the `session.search` response
|
||||
* schema caps `items` at this constant, so every transport (fixture included)
|
||||
* reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
@@ -228,7 +240,10 @@ export class SessionsService implements ISessions {
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
constructor(
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
@@ -307,6 +322,20 @@ export class SessionsService implements ISessions {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the Host's visible message-content index. Results stay
|
||||
* request-local; the list snapshot remains the metadata authority.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search.
|
||||
* @returns bounded results or a business/transport error.
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
|
||||
return this.manager.search(query, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mux stream envelope into the Session object layer.
|
||||
* @param envelope - validated mux stream envelope.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
|
||||
@@ -12,12 +13,13 @@ import type {
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
import type {
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
|
||||
PromptError, QueuedMessage, RunningToolCall,
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
|
||||
OpenState, PromptError, QueuedMessage, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
import { FoldAdapter } from './fold-adapter.ts'
|
||||
import { TranscriptAdapter } from './transcript-adapter.ts'
|
||||
import { displayFailureMessage } from './failure-display.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
@@ -26,6 +28,10 @@ import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
// Browser bundles cannot value-import the host timeout library. This protocol
|
||||
// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests.
|
||||
const MAX_RETRY_DELAY_MS = 2_147_483_647
|
||||
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
/**
|
||||
@@ -85,12 +91,13 @@ export class Session implements SessionFace {
|
||||
* passes drop all writes once the generation moves on. */
|
||||
private openGeneration = 0
|
||||
private loadingOlder = false
|
||||
private readonly foldAdapter = new FoldAdapter()
|
||||
private readonly transcript = new TranscriptAdapter()
|
||||
private partial: PartialAccumulator | null = null
|
||||
private openCalls = new Map<string, RunningToolCall>()
|
||||
/** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
|
||||
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
|
||||
private frozenNodes: ConversationNode[] = []
|
||||
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
|
||||
* Derived from window events and rebuilt with partial/openCalls; the transcript is
|
||||
* seq-monotonic, so a plain seq merge preserves event order. */
|
||||
private derivedNodes: ConversationNode[] = []
|
||||
private pending = new Map<string, PendingInteraction>()
|
||||
// Revision counters preserve array identity when derived content is unchanged, so
|
||||
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
|
||||
@@ -100,12 +107,12 @@ export class Session implements SessionFace {
|
||||
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
|
||||
private pendingRev = 0
|
||||
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
|
||||
private derivedRev = 0
|
||||
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
|
||||
private queued: QueuedMessage[] = []
|
||||
private queueRev = 0
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
@@ -241,7 +248,8 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
|
||||
* Stop the active turn while the Host preserves pending inbox work; failures
|
||||
* land in promptError (same error-strip display slot).
|
||||
* @returns the cancel result.
|
||||
*/
|
||||
async cancel(): Promise<RpcResult<{ accepted: true }>> {
|
||||
@@ -331,7 +339,7 @@ export class Session implements SessionFace {
|
||||
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
|
||||
this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head)
|
||||
this.rebuildDerivedFromWindow()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] loadOlder failed:', error)
|
||||
@@ -564,7 +572,7 @@ export class Session implements SessionFace {
|
||||
this.views = entries.map(e => e.view)
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.transcript.reset(this.events, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
const buffered = this.liveBuffer
|
||||
@@ -579,14 +587,15 @@ export class Session implements SessionFace {
|
||||
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
|
||||
this.events.push(event)
|
||||
this.views.push(view)
|
||||
this.foldAdapter.append(event, view)
|
||||
this.transcript.append(event, view)
|
||||
this.applyEventSideEffects(event, view)
|
||||
}
|
||||
|
||||
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
|
||||
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
|
||||
* expected reconnect-window artifact, repaired by refetch — never fed to the fold to trip
|
||||
* its continuity assertion into the degraded view). */
|
||||
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
|
||||
* raw range, which is what lets the transcript render every event between its ends and lets a
|
||||
* compaction checkpoint find its own provenance. */
|
||||
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (this.openState === 'loading' || this.stitching) {
|
||||
this.liveBuffer.push({ event, view })
|
||||
@@ -625,8 +634,28 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/** Per-event side effects (right column of the §A.9 dispatch table):
|
||||
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
|
||||
* chunk/retry projection and openCalls add-remove. */
|
||||
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
|
||||
const eventType = event.type as string
|
||||
if (eventType === 'llm/retry') {
|
||||
const data = parseRetryEventData(event.data)
|
||||
if (data === null) {
|
||||
console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`)
|
||||
return
|
||||
}
|
||||
if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
|
||||
this.partial = null
|
||||
}
|
||||
this.derivedNodes.push({
|
||||
kind: 'model-retry',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
retryState: 'scheduled',
|
||||
...data,
|
||||
})
|
||||
this.derivedRev++
|
||||
return
|
||||
}
|
||||
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
|
||||
// the host-side dsh-tools plugin whose types cannot enter the client
|
||||
// program (its host Context merges collide with the client's), so this
|
||||
@@ -687,6 +716,10 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
|
||||
return
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
|
||||
@@ -715,6 +748,25 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
|
||||
this.settleScheduledRetry('cancelled', event.data.turn)
|
||||
}
|
||||
if (
|
||||
event.data.reason.kind === 'error'
|
||||
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
|
||||
) {
|
||||
const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason
|
||||
this.derivedNodes.push({
|
||||
kind: 'turn-error',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
turn: event.data.turn,
|
||||
step: event.data.reason.step,
|
||||
message: displayFailureMessage(failure),
|
||||
...(failure.code === undefined ? {} : { code: failure.code }),
|
||||
})
|
||||
this.derivedRev++
|
||||
}
|
||||
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
|
||||
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
|
||||
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
|
||||
@@ -724,12 +776,12 @@ export class Session implements SessionFace {
|
||||
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
|
||||
if (visible) {
|
||||
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
|
||||
this.frozenNodes.push({
|
||||
this.derivedNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: this.partial.turn, step: this.partial.step,
|
||||
blocks, interrupted: true,
|
||||
})
|
||||
this.frozenRev++
|
||||
this.derivedRev++
|
||||
}
|
||||
this.partial = null
|
||||
}
|
||||
@@ -739,7 +791,7 @@ export class Session implements SessionFace {
|
||||
this.openCalls.delete(callId)
|
||||
this.callsRev++
|
||||
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
|
||||
this.frozenNodes.push({
|
||||
this.derivedNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
@@ -747,7 +799,7 @@ export class Session implements SessionFace {
|
||||
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView, resultView: null,
|
||||
})
|
||||
this.frozenRev++
|
||||
this.derivedRev++
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -756,15 +808,36 @@ export class Session implements SessionFace {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
/**
|
||||
* Settle the newest scheduled retry, optionally restricted to its failed turn.
|
||||
* @param retryState - next client projection state to publish.
|
||||
* @param turn - failed turn required for cancellation; omitted for the next retry turn start.
|
||||
*/
|
||||
private settleScheduledRetry(
|
||||
retryState: Exclude<ModelRetryNode['retryState'], 'scheduled'>,
|
||||
turn?: number,
|
||||
): void {
|
||||
const index = this.derivedNodes.findLastIndex(node =>
|
||||
node.kind === 'model-retry'
|
||||
&& node.retryState === 'scheduled'
|
||||
&& (turn === undefined || node.turn === turn))
|
||||
if (index < 0) return
|
||||
const node = this.derivedNodes[index]
|
||||
/* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */
|
||||
if (node?.kind !== 'model-retry') return
|
||||
this.derivedNodes[index] = { ...node, retryState }
|
||||
this.derivedRev++
|
||||
}
|
||||
|
||||
/** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps
|
||||
* paging/stitching consistent, and makes live handling and history replay converge on the same
|
||||
* retry notices and interrupted nodes. */
|
||||
private rebuildDerivedFromWindow(): void {
|
||||
this.partial = null
|
||||
this.openCalls.clear()
|
||||
this.callsRev++
|
||||
this.frozenNodes = []
|
||||
this.frozenRev++
|
||||
this.derivedNodes = []
|
||||
this.derivedRev++
|
||||
this.codeDispatches = new Map()
|
||||
this.dispatchesRev++
|
||||
for (let i = 0; i < this.events.length; i++) {
|
||||
@@ -780,18 +853,18 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
private buildSnapshot(): ConversationSnapshot {
|
||||
const { nodes: folded, degraded } = this.foldAdapter.nodes()
|
||||
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
|
||||
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
|
||||
// reference across snapshot swaps (§A.9.4).
|
||||
const projected = this.transcript.nodes()
|
||||
// Derived interruption nodes ride fractional seqs while retry notices keep their event seq.
|
||||
// The transcript is seq-monotonic, so sorting the union preserves flow order. Cache the
|
||||
// merge on (projected reference, derivedRev) to retain identity across unrelated swaps.
|
||||
let nodes: readonly ConversationNode[]
|
||||
if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
|
||||
if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) {
|
||||
nodes = this.nodesCache.value
|
||||
} else {
|
||||
nodes = this.frozenNodes.length === 0
|
||||
? folded
|
||||
: [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
|
||||
this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
|
||||
nodes = this.derivedNodes.length === 0
|
||||
? projected
|
||||
: [...projected, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
|
||||
this.nodesCache = { projected, derivedRev: this.derivedRev, value: nodes }
|
||||
}
|
||||
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
|
||||
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
|
||||
@@ -809,7 +882,6 @@ export class Session implements SessionFace {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
foldDegraded: degraded,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
pending: this.pendingCache.value,
|
||||
@@ -835,6 +907,58 @@ export class Session implements SessionFace {
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the plugin-owned payload at the session-event wire boundary. */
|
||||
function parseRetryEventData(value: unknown): LlmRetryEventData | null {
|
||||
if (value === null || typeof value !== 'object') return null
|
||||
const data = value as Record<string, unknown>
|
||||
const failure = data.failure
|
||||
if (failure === null || typeof failure !== 'object') return null
|
||||
const failureData = failure as Record<string, unknown>
|
||||
if (!nonNegativeSafeInteger(data.turn)
|
||||
|| !nonNegativeSafeInteger(data.step)
|
||||
|| typeof data.provider !== 'string'
|
||||
|| data.provider.length === 0
|
||||
|| typeof data.policyKey !== 'string'
|
||||
|| data.policyKey.length === 0
|
||||
|| !positiveSafeInteger(data.retry)
|
||||
|| typeof data.delayMs !== 'number'
|
||||
|| !Number.isFinite(data.delayMs)
|
||||
|| data.delayMs < 0
|
||||
|| data.delayMs > MAX_RETRY_DELAY_MS
|
||||
|| typeof failureData.message !== 'string'
|
||||
|| failureData.message.length === 0
|
||||
|| typeof failureData.code !== 'string'
|
||||
|| failureData.code.length === 0) return null
|
||||
if (data.mode === 'normal') {
|
||||
if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null
|
||||
} else if (data.mode === 'always') {
|
||||
if ('maxRetries' in data) return null
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
if (failureData.status !== undefined
|
||||
&& (typeof failureData.status !== 'number'
|
||||
|| !Number.isInteger(failureData.status)
|
||||
|| failureData.status < 100
|
||||
|| failureData.status > 599)) return null
|
||||
if (failureData.providerRetryAfterMs !== undefined
|
||||
&& (typeof failureData.providerRetryAfterMs !== 'number'
|
||||
|| !Number.isFinite(failureData.providerRetryAfterMs)
|
||||
|| failureData.providerRetryAfterMs <= 0)) return null
|
||||
if (failureData.requestId !== undefined
|
||||
&& (typeof failureData.requestId !== 'string'
|
||||
|| failureData.requestId.length === 0)) return null
|
||||
return data as unknown as LlmRetryEventData
|
||||
}
|
||||
|
||||
function nonNegativeSafeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: unknown): value is number {
|
||||
return nonNegativeSafeInteger(value) && value > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The composerPhase judgment — the single site that knows the predicate
|
||||
* (consumers switch on the result, never re-derive). Monotone per session
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// TranscriptAdapter: the human transcript projected from the raw event window
|
||||
// in LOG order. The model-visible surface deliberately shadows replaced ranges,
|
||||
// so it is the wrong source for conversation a reader already saw; this adapter
|
||||
// keeps every append-origin event at its own log position and contributes one
|
||||
// marker node per landed compaction checkpoint. Node order is therefore
|
||||
// seq-monotonic by construction — no surface fold, no padding sentinels, no
|
||||
// seq === index assertion to satisfy, and no degradation branch.
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// Subpath export (package.json exports "./surface", alias added for this): all value imports
|
||||
// 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 { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
// Cordis-free leaf subpath (the dsh-commands/brand shape): the seam's own
|
||||
// declaration of the checkpoint source, reachable as a TYPE from this program.
|
||||
// The package ROOT is not — it reaches dsh-session's root, whose Context merge
|
||||
// declares the HOST `sessions: SessionStore` against this program's
|
||||
// `sessions: ISessions` (TS2717, the one-program-per-side rule in
|
||||
// docs/development.md).
|
||||
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
|
||||
/**
|
||||
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
|
||||
* at COMPILE time: renaming it there fails this annotation (`TS2322`). The
|
||||
* import stays type-only because a value import would fail the client purity
|
||||
* gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are
|
||||
* forbidden in a browser bundle — while an erased type never reaches it.
|
||||
* `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
|
||||
*/
|
||||
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
|
||||
|
||||
/** In-window tool/call index entry used to materialize result cards. */
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms of the tool/call event. */
|
||||
time: number
|
||||
/** Wire view riding the tool/call (envelope-level; never inside the event). */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the eight-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
// Injected context (plugin/goal source) folds to a context node, not a
|
||||
// user message; only a direct human prompt is a user node. A compaction
|
||||
// checkpoint never reaches here (isCompactCheckpoint routes it away).
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
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.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
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,
|
||||
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
|
||||
callTime: call?.time ?? null,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error !== undefined ? { error: event.data.error } : {}),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
}
|
||||
}
|
||||
/* v8 ignore next 2 -- defensive arm: only the four surface-eligible types
|
||||
can be append-origin, and each has a case above; reachable only if core
|
||||
adds an eligible type. */
|
||||
default:
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event is a landed compaction checkpoint — all three conditions,
|
||||
* matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the
|
||||
* compaction seam's checkpoint plugin source, that REPLACED a surface range. A
|
||||
* plugin-sourced `user/message` that appends is injected context (a
|
||||
* session-reference card), not a compaction; a replacement `tool/result` is an
|
||||
* in-place prune and a replacement `assistant/message` a generic rewrite, and
|
||||
* both mark no boundary in the conversation.
|
||||
* @param event - the raw window event.
|
||||
* @returns true when the event compacted a surface range.
|
||||
*/
|
||||
function isCompactCheckpoint(event: SessionEvent): boolean {
|
||||
if (event.type !== 'user/message') return false
|
||||
const source = event.data.source
|
||||
return source.kind === 'plugin' && source.plugin === COMPACT_PLUGIN
|
||||
&& isReplacementSurfaceEvent(event)
|
||||
}
|
||||
|
||||
/** Whether an event contributes a node to the human transcript. */
|
||||
function isTranscriptEvent(event: SessionEvent): boolean {
|
||||
return isAppendSurfaceEvent(event) || isCompactCheckpoint(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenated text of a `compact/summary` payload, or null when it carries no
|
||||
* usable text. The payload is a `ContentBlock[]` whose union is
|
||||
* merge-extensible, so a non-text block is skipped rather than discarding the
|
||||
* text beside it; a payload with no text block at all falls to null through the
|
||||
* empty check.
|
||||
*/
|
||||
function compactSummaryText(event: SessionEvent): string | null {
|
||||
const summary = (event.data as unknown as { summary?: unknown }).summary
|
||||
if (!Array.isArray(summary)) return null
|
||||
let text = ''
|
||||
for (const block of summary as readonly unknown[]) {
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue
|
||||
text += candidate.text
|
||||
}
|
||||
return text.trim() === '' ? null : text
|
||||
}
|
||||
|
||||
/**
|
||||
* One landed checkpoint -> the human-facing compaction marker. The summary text
|
||||
* comes from the checkpoint's own provenance (`sourceEventSeqs` names the
|
||||
* `compact/summary` event), never from the framed checkpoint payload, which is
|
||||
* an instruction envelope written for the model. A window cut that left the
|
||||
* provenance outside soft-falls to `summary: null` (a non-expandable marker),
|
||||
* the same posture as a call-less tool result.
|
||||
*/
|
||||
function materializeCompaction(
|
||||
checkpoint: SessionEvent,
|
||||
eventIndex: ReadonlyMap<number, SessionEvent>,
|
||||
): CompactionSummaryNode {
|
||||
const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs
|
||||
let summary: string | null = null
|
||||
for (const seq of sources ?? []) {
|
||||
const candidate = eventIndex.get(seq)
|
||||
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
|
||||
summary = compactSummaryText(candidate)
|
||||
break
|
||||
}
|
||||
return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary }
|
||||
}
|
||||
|
||||
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
|
||||
export class TranscriptAdapter {
|
||||
/** Window events by seq: provenance lookup for a checkpoint's summary. */
|
||||
private eventIndex = new Map<number, SessionEvent>()
|
||||
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
|
||||
private projected: ConversationNode[] = []
|
||||
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 it is not a surface
|
||||
* event and never joins the transcript projection; this index folds the pair
|
||||
* (done settles its run's node in place) and nodes() merges the products in
|
||||
* 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>()
|
||||
/** Projection revision, bumped only when a transcript node or a command node actually
|
||||
* changed, keying the nodes() result cache: an unchanged projection returns the previous
|
||||
* ARRAY reference, not just cached elements — the snapshot's reference-stability contract
|
||||
* (§A.9.4) starts here, and a chunk storm bumps nothing at all. */
|
||||
private rev = 0
|
||||
private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null
|
||||
|
||||
/**
|
||||
* Window rebuild (after open/resync/page prepend): re-index the raw window
|
||||
* and re-project the transcript.
|
||||
* @param events - the new window contents (seq-ascending).
|
||||
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
|
||||
*/
|
||||
reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void {
|
||||
this.rev++
|
||||
this.eventIndex = new Map()
|
||||
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) continue
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
}
|
||||
// Indexes first, then project: a tool/result materializes against the
|
||||
// complete call index, and a checkpoint against the complete event index.
|
||||
const projected: ConversationNode[] = []
|
||||
for (const event of events) {
|
||||
if (isTranscriptEvent(event)) projected.push(this.materialize(event))
|
||||
}
|
||||
this.projected = projected
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail append (live session/event): index the event and, when it belongs to
|
||||
* the transcript, extend the projection by one copy-on-write node so a
|
||||
* published array never mutates. An event that changes no node (a chunk
|
||||
* storm) bumps no revision, so nodes() keeps returning the same array
|
||||
* reference.
|
||||
* @param event - the live event (seq = window tail + 1).
|
||||
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
|
||||
*/
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, view)
|
||||
if (this.indexCommand(event)) this.rev++
|
||||
if (!isTranscriptEvent(event)) return
|
||||
this.projected = [...this.projected, this.materialize(event)]
|
||||
this.rev++
|
||||
}
|
||||
|
||||
/**
|
||||
* The current transcript node array. Same revision -> same array reference
|
||||
* (memo boundary); node objects are materialized once, so an unchanged node
|
||||
* keeps its identity across appends.
|
||||
* @returns transcript nodes in log order, command nodes merged in by seq.
|
||||
*/
|
||||
nodes(): readonly ConversationNode[] {
|
||||
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
|
||||
// Command nodes fold outside the transcript (log-only events); merge by
|
||||
// seq. Both inputs are seq-ascending (log order and run-index insertion
|
||||
// order are the same order), so one linear merge keeps flow order.
|
||||
let nodes = this.projected
|
||||
if (this.commandIdx.size > 0) {
|
||||
nodes = []
|
||||
const commands = [...this.commandIdx.values()]
|
||||
let next = 0
|
||||
for (const node of this.projected) {
|
||||
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)
|
||||
}
|
||||
this.nodesResult = { rev: this.rev, value: nodes }
|
||||
return nodes
|
||||
}
|
||||
|
||||
/** Materialize one transcript event against the complete current indexes. */
|
||||
private materialize(event: SessionEvent): ConversationNode {
|
||||
return isCompactCheckpoint(event)
|
||||
? materializeCompaction(event, this.eventIndex)
|
||||
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one command lifecycle event into its node (run mints, done settles in
|
||||
* place; done-only soft-falls).
|
||||
* @returns whether the command index changed, so callers can bump the revision.
|
||||
*/
|
||||
private indexCommand(event: SessionEvent): boolean {
|
||||
// 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 true
|
||||
}
|
||||
if ((event.type as string) !== 'command/done') return false
|
||||
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 true
|
||||
}
|
||||
// Settle in place: a fresh node object (published references stay immutable).
|
||||
this.commandIdx.set(data.commandId, { ...run, outcome })
|
||||
return true
|
||||
}
|
||||
|
||||
private indexCall(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (event.type === 'tool/result') {
|
||||
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'tool/call') return
|
||||
this.callIdx.set(String(event.data.callId), {
|
||||
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
|
||||
time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
// No backfill into already-materialized tool-result nodes for this callId
|
||||
// (window order puts the call before its result; cannot happen on the normal path).
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,14 @@ export type WorkspaceListPhase = 'pending' | 'ready'
|
||||
/** Immutable workspace-list snapshot. */
|
||||
export interface WorkspaceListSnapshot {
|
||||
items: readonly WorkspaceView[]
|
||||
/**
|
||||
* Registry-global archive set in Host order (hidden from grouping
|
||||
* surfaces; accounting slots retained). A plain array, not a Set: public
|
||||
* snapshot state stays in the store engine's plain-data vocabulary
|
||||
* (immer drafts reject Sets without the MapSet plugin); membership
|
||||
* lookups build their own transient Set where they need one.
|
||||
*/
|
||||
archivedSessionIds: readonly SessionId[]
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
phase: WorkspaceListPhase
|
||||
error: RpcError | null
|
||||
@@ -28,11 +36,21 @@ export class WorkspaceManager {
|
||||
private items: Workspace[] = []
|
||||
private itemViewsSource: readonly Workspace[] | null = null
|
||||
private itemViewsCache: readonly WorkspaceView[] = []
|
||||
// Full-snapshot state (list response / unary response / changed frame all
|
||||
// carry the complete set), so deltas never merge — installs replace.
|
||||
private archivedSessionIds: readonly SessionId[] = []
|
||||
private state: WorkspaceListSnapshot['state'] = 'idle'
|
||||
private phase: WorkspaceListPhase = 'pending'
|
||||
private error: RpcError | null = null
|
||||
private inflight: Promise<void> | null = null
|
||||
private refreshFrames: WorkspaceDelta[] | null = null
|
||||
/**
|
||||
* True once a frame or unary echo installed the archive set while a list
|
||||
* request was in flight: that install is newer than the pending baseline,
|
||||
* so the baseline's (older) set must not roll it back — the archive
|
||||
* mirror of replaying refreshFrames over the item baseline.
|
||||
*/
|
||||
private archivedSupersedesRefresh = false
|
||||
/**
|
||||
* Ids this process has seen removed, kept for the connection's lifetime so
|
||||
* a late changed frame or a stale baseline row cannot resurrect a deleted
|
||||
@@ -77,6 +95,7 @@ export class WorkspaceManager {
|
||||
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
|
||||
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
|
||||
this.installViews(items)
|
||||
if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds)
|
||||
this.state = 'idle'
|
||||
this.phase = 'ready'
|
||||
} else {
|
||||
@@ -90,6 +109,7 @@ export class WorkspaceManager {
|
||||
this.error = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
this.refreshFrames = null
|
||||
this.archivedSupersedesRefresh = false
|
||||
this.inflight = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
@@ -158,6 +178,18 @@ export class WorkspaceManager {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive one session in the registry-global set, then install the
|
||||
* returned full set without waiting for the changed frame.
|
||||
* @param sessionId - session to archive.
|
||||
* @returns the wire result.
|
||||
*/
|
||||
async archiveSession(sessionId: SessionId): Promise<RpcResult<{ archivedSessionIds: SessionId[] }>> {
|
||||
const { result } = await this.api.workspace.archiveSession({ sessionId })
|
||||
if (result.ok) this.installArchived(result.value.archivedSessionIds)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-frame entry. Non-workspace frames are ignored so the runtime can
|
||||
* fan one host stream out to both object managers.
|
||||
@@ -166,6 +198,9 @@ export class WorkspaceManager {
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
|
||||
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
|
||||
else if (envelope.payload.type === 'host/archived-sessions-changed') {
|
||||
this.installArchived(envelope.payload.archivedSessionIds)
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-pull the baseline after each connection generation. */
|
||||
@@ -194,12 +229,26 @@ export class WorkspaceManager {
|
||||
private buildSnapshot(): WorkspaceListSnapshot {
|
||||
return {
|
||||
items: this.itemViews(),
|
||||
archivedSessionIds: this.archivedSessionIds,
|
||||
state: this.state,
|
||||
phase: this.phase,
|
||||
error: this.error,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the archive set when membership actually changed (array identity
|
||||
* backs Object.is short-circuits). Host snapshots are append-ordered, so
|
||||
* positional comparison is exact, not merely heuristic.
|
||||
*/
|
||||
private installArchived(archivedSessionIds: readonly SessionId[]): void {
|
||||
if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true
|
||||
if (archivedSessionIds.length === this.archivedSessionIds.length
|
||||
&& archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return
|
||||
this.archivedSessionIds = [...archivedSessionIds]
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Upsert one Host view, optionally retaining the local object that materialized it. */
|
||||
private upsert(view: WorkspaceView, identity?: Workspace): void {
|
||||
if (this.removedIds.has(view.workspaceId)) return
|
||||
|
||||
@@ -14,6 +14,14 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
|
||||
/** Workspace list plus the two-baseline readiness and default-target projection. */
|
||||
export interface WorkspaceListState {
|
||||
items: readonly WorkspaceView[]
|
||||
/**
|
||||
* Registry-global archive set in Host order: grouping surfaces hide these
|
||||
* sessions everywhere (workspace groups and the ungrouped bucket) while
|
||||
* their session logs and workspace accounting slots remain. A plain array
|
||||
* (store-engine vocabulary; immer drafts reject Sets) — membership lookups
|
||||
* build their own transient Set.
|
||||
*/
|
||||
archivedSessionIds: readonly SessionId[]
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
phase: WorkspaceListPhase
|
||||
error: RpcError | null
|
||||
@@ -58,7 +66,7 @@ export class WorkspacesService implements IWorkspaces {
|
||||
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
|
||||
this.manager = new WorkspaceManager(api)
|
||||
this.list = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'pending', error: null,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null,
|
||||
baselinesReady: false, recentWorkspaceId: undefined,
|
||||
})
|
||||
this.manager.subscribe(() => { this.project() })
|
||||
@@ -88,10 +96,14 @@ export class WorkspacesService implements IWorkspaces {
|
||||
if (inflight !== undefined) return inflight
|
||||
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
|
||||
// canon; summary cwd is the session header passthrough of the same canon).
|
||||
// An archived blank is never reused: reuse would open a session no
|
||||
// grouping surface can show, so New Session mints a fresh one instead.
|
||||
const archived = this.list.getSnapshot().archivedSessionIds
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
for (const id of sessions.ids) {
|
||||
const summary = sessions.byId[id]
|
||||
if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id
|
||||
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
|
||||
&& !archived.includes(summary.id)) return summary.id
|
||||
}
|
||||
const attempt = this.sessions.create({ workspaceId })
|
||||
.finally(() => { this.connecting.delete(workspaceId) })
|
||||
@@ -249,6 +261,17 @@ export class WorkspacesService implements IWorkspaces {
|
||||
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a session into the registry-global set. Clearing an archived
|
||||
* current selection is the projection sweep's job (one rule for the local
|
||||
* echo and a remote tab's frame alike).
|
||||
* @param sessionId - session to archive.
|
||||
*/
|
||||
async archiveSession(sessionId: SessionId): Promise<void> {
|
||||
const result = await this.manager.archiveSession(sessionId)
|
||||
if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
|
||||
* @param workspaceId - owning workspace.
|
||||
@@ -291,8 +314,17 @@ export class WorkspacesService implements IWorkspaces {
|
||||
const workspace = this.manager.getSnapshot()
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
|
||||
// An archived current selection clears into the New Session view state —
|
||||
// a hidden row must not stay open behind the list. Sweeping here covers
|
||||
// every install path with one rule: the local unary echo, another tab's
|
||||
// changed frame, and a reconnect baseline restoring a persisted
|
||||
// selection that was archived while this client was away.
|
||||
if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) {
|
||||
this.sessions.clear()
|
||||
}
|
||||
this.list.set({
|
||||
items: workspace.items,
|
||||
archivedSessionIds: workspace.archivedSessionIds,
|
||||
state: workspace.state,
|
||||
phase: workspace.phase,
|
||||
error: workspace.error,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
@@ -50,6 +51,8 @@ describe('runtime client apply', () => {
|
||||
const workspaces = bench.ctx.get('workspaces')
|
||||
expect(sessions !== undefined).toBe(true)
|
||||
expect(workspaces !== undefined).toBe(true)
|
||||
// The bound the wire schema enforces, not a per-connection negotiation.
|
||||
expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
|
||||
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
|
||||
expect(bench.sinks).toBeDefined()
|
||||
|
||||
|
||||
49
packages/client/runtime/tests/compact-checkpoint-pin.spec.ts
Normal file
49
packages/client/runtime/tests/compact-checkpoint-pin.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Behavioral half of the compaction-checkpoint drift trap.
|
||||
*
|
||||
* `TranscriptAdapter` pins its plugin literal to the seam's own declaration at
|
||||
* compile time through a type-only import of `dsh-compact/checkpoint`, so
|
||||
* renaming the seam's plugin already fails `tsc`. This spec covers the same
|
||||
* drift from the other side — end to end through the adapter, driving it with a
|
||||
* checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` value and
|
||||
* checking the seam's own predicate agrees. Both values come from the
|
||||
* cordis-free checkpoint leaf, so the client test program never loads the host
|
||||
* package root or its `Context` merges.
|
||||
*/
|
||||
|
||||
import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
|
||||
|
||||
/** A replacement user message stamped with the seam's own canonical source. */
|
||||
function canonicalCheckpoint(seq: number): SessionEvent {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq,
|
||||
time: 1_700_000_000_000 + seq,
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}),
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
describe('compaction checkpoint recognition', () => {
|
||||
it('recognizes a checkpoint carrying the seam-canonical source', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([canonicalCheckpoint(1)])
|
||||
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }])
|
||||
})
|
||||
|
||||
it("agrees with the seam's own predicate on the source it recognizes", () => {
|
||||
// Both sides answer the same question about the same value: if the seam
|
||||
// renames its plugin, this equality is what breaks.
|
||||
const checkpoint = canonicalCheckpoint(1)
|
||||
expect(checkpoint.type === 'user/message' && isCompactCheckpointSource(checkpoint.data.source)).toBe(true)
|
||||
expect(COMPACT_CHECKPOINT_SOURCE).toEqual({ kind: 'plugin', plugin: 'compact' })
|
||||
})
|
||||
})
|
||||
@@ -63,12 +63,51 @@ export const ev = {
|
||||
}),
|
||||
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
|
||||
retry: (
|
||||
seq: number,
|
||||
turn: number,
|
||||
step = 0,
|
||||
retry = 1,
|
||||
maxRetries = 2,
|
||||
delayMs = 500,
|
||||
message = 'temporary transport failure',
|
||||
): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn, step,
|
||||
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
|
||||
retry, maxRetries, delayMs,
|
||||
failure: { code: 'TRANSPORT', message },
|
||||
},
|
||||
}),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
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 } } }),
|
||||
/** A compaction's log-only `compact/summary` provenance record. */
|
||||
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
|
||||
at(seq, { type: 'compact/summary', data: {
|
||||
summary: text(summary),
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs: [start, end],
|
||||
shadowedTokenCount: 100,
|
||||
provider: 'fake',
|
||||
model: 'compact-1',
|
||||
} }),
|
||||
/** The replacement user message a compaction backend lands (the checkpoint). */
|
||||
compactCheckpoint: (seq: number, summarySeq: number, start: number, end: number): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'user/message',
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [summarySeq, start, end],
|
||||
data: createUserMessage({
|
||||
content: text('<context_checkpoint>model only</context_checkpoint>'),
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -61,6 +61,8 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ items: [], hasMore: false }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
@@ -106,12 +108,17 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
lastSearchSignal: AbortSignal | undefined
|
||||
|
||||
// Parameters carry local structural annotations: the CI lint lane runs
|
||||
// without built lib/, so IApiClient's indexed-access types collapse to any
|
||||
// and inferred parameters would trip no-unsafe-argument.
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
|
||||
search: (payload: unknown, signal?: AbortSignal) => {
|
||||
this.lastSearchSignal = signal
|
||||
return this.record('session.search', payload, this.onSearch(payload))
|
||||
},
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
@@ -133,7 +140,10 @@ export class FakeApiClient implements IApiClient {
|
||||
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
// The archive-set field defaults at the binding below so list stubs keep
|
||||
// the pre-archive `{ items }` shape; a stub carrying the field wins.
|
||||
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[]; archivedSessionIds?: never[] }>> =
|
||||
() => Promise.resolve(ok({ items: [] }))
|
||||
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
|
||||
|
||||
@@ -146,13 +156,22 @@ export class FakeApiClient implements IApiClient {
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
onWorkspaceArchiveSession: (payload: unknown) => Promise<RpcResponse<{ archivedSessionIds: SessionId[] }>> =
|
||||
payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
|
||||
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => (
|
||||
response.result.ok
|
||||
? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } }
|
||||
: response
|
||||
)) as ReturnType<IApiClient['workspace']['list']>),
|
||||
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
|
||||
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
|
||||
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
|
||||
insertSessionBefore: (payload: unknown) =>
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
|
||||
archiveSession: (payload: unknown) =>
|
||||
this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)),
|
||||
}
|
||||
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
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
|
||||
* materialization, call-index backfill, and the degraded linear-scan branch.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
|
||||
|
||||
describe('FoldAdapter', () => {
|
||||
it('folds a baseSeq>0 window through padding sentinels with correct seqs', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const window = plainTurn(100, 5, '偏移问', '偏移答')
|
||||
adapter.reset(window, 100)
|
||||
const { nodes, degraded } = adapter.nodes()
|
||||
expect(degraded).toBe(false)
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
|
||||
})
|
||||
|
||||
it('appends incrementally keeping old node references (cache identity)', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
|
||||
const first = adapter.nodes()
|
||||
expect(adapter.nodes()).toBe(first)
|
||||
adapter.append(ev.user(6, '追加'))
|
||||
const second = adapter.nodes()
|
||||
expect(second.nodes).toHaveLength(3)
|
||||
expect(second.nodes[0]).toBe(first.nodes[0])
|
||||
expect(second.nodes[1]).toBe(first.nodes[1])
|
||||
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
ev.user(1, 'b'),
|
||||
at(2, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
at(3, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 2, end: 1 },
|
||||
sourceEventSeqs: [2, 1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary 2' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
|
||||
id: context.id,
|
||||
parentId: context.parentId,
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
|
||||
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
|
||||
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
|
||||
])
|
||||
})
|
||||
|
||||
it('materializes all six node variants with field mapping', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const events = [
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
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', '结果'),
|
||||
]
|
||||
adapter.reset(events, 0)
|
||||
const { nodes } = adapter.nodes()
|
||||
const kinds = nodes.map(n => n.kind)
|
||||
expect(kinds).toContain('user')
|
||||
expect(kinds).toContain('assistant')
|
||||
expect(kinds).toContain('steering')
|
||||
expect(kinds).toContain('context')
|
||||
const result = nodes.find(n => n.kind === 'tool-result')
|
||||
expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
|
||||
})
|
||||
|
||||
it('returns call:null for a tool-result whose call fell outside the window', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)
|
||||
const { nodes } = adapter.nodes()
|
||||
expect(nodes[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
|
||||
})
|
||||
|
||||
it('materializes surface-eligible types it does not know as unknown nodes', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([at(0, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } })], 0)
|
||||
const { nodes } = adapter.nodes()
|
||||
// Either the fold surfaces it (unknown node) or skips it as non-eligible — both are valid
|
||||
// shapes; what matters is no throw and no misclassification into a known kind.
|
||||
for (const node of nodes) expect(node.kind).toBe('unknown')
|
||||
})
|
||||
|
||||
it('degrades to the lenient linear scan when the fold throws, and stays degraded', () => {
|
||||
const adapter = new 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,
|
||||
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 {
|
||||
adapter.reset(window, 10)
|
||||
const first = adapter.nodes()
|
||||
expect(first.degraded).toBe(true)
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
expect(first.nodes.map(n => n.seq)).toEqual([10, 11]) // linear scan: append order, bad op ignored
|
||||
adapter.append(ev.user(12, '降级后追加')) // bump rev so the cached result is not reused
|
||||
const second = adapter.nodes()
|
||||
expect(second.degraded).toBe(true) // sticky: no re-throw loop, straight to the linear scan
|
||||
expect(second.nodes[0]).toBe(first.nodes[0]) // cache still serves node identity
|
||||
expect(second.nodes.map(n => n.seq)).toEqual([10, 11, 12])
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('silently degrades when a replacement needs an earlier history page', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([
|
||||
at(10, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 1, end: 3 },
|
||||
sourceEventSeqs: [1, 3],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'partial summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
ev.user(11, 'newer message'),
|
||||
], 10)
|
||||
|
||||
expect(adapter.nodes()).toMatchObject({
|
||||
degraded: true,
|
||||
nodes: [{ seq: 10 }, { seq: 11 }],
|
||||
})
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('silently degrades when a live replacement needs an earlier history page', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
adapter.reset([ev.user(10, 'window head')], 10)
|
||||
adapter.append(at(11, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
sourceEventSeqs: [1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'live summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
expect(adapter.nodes()).toMatchObject({
|
||||
degraded: true,
|
||||
nodes: [{ seq: 10 }, { seq: 11 }],
|
||||
})
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
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' } })
|
||||
})
|
||||
|
||||
it('projects assistant timing and the active request header from history', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.stepStart(0, 1, 2),
|
||||
at(1, { type: 'request/header', data: {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'first' },
|
||||
tools: [],
|
||||
},
|
||||
} }),
|
||||
ev.chunkStart(2, 1, 2),
|
||||
ev.chunkText(3, 1, 'token', 2),
|
||||
ev.assistant(4, 1, 'done', 2),
|
||||
ev.stepStart(5, 2, 1),
|
||||
ev.chunkText(6, 2, 'next', 1),
|
||||
ev.assistant(7, 2, 'next done', 1),
|
||||
].map(event => ({ event })))
|
||||
|
||||
expect(projection.eventNodes[0]).toMatchObject({
|
||||
kind: 'assistant',
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_000,
|
||||
firstTokenTime: 1_700_000_000_003,
|
||||
completedTime: 1_700_000_000_004,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
|
||||
expect(projection.eventNodes.at(-1)).toMatchObject({
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_005,
|
||||
firstTokenTime: 1_700_000_000_006,
|
||||
completedTime: 1_700_000_000_007,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes the in-window call index for runningCalls material', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
|
||||
expect(adapter.callIndex.get('c9')).toMatchObject({ name: 'slow', turn: 1 })
|
||||
adapter.append(ev.toolCall(1, 1, 'c10', 'fast', '{}'))
|
||||
expect(adapter.callIndex.size).toBe(2)
|
||||
})
|
||||
|
||||
it('attaches wire views: callView into the call index, resultView onto the node by seq', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const events = [
|
||||
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
|
||||
ev.toolResult(1, 1, 'c1', 'listing'),
|
||||
]
|
||||
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
|
||||
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
|
||||
adapter.reset(events, 0, [callView, resultView] as never)
|
||||
expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } })
|
||||
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
|
||||
expect(node).toMatchObject({ callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' } })
|
||||
})
|
||||
|
||||
it('attaches views on the live append path and defaults to null without views', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) // no views argument: legacy-shaped call
|
||||
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
|
||||
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
|
||||
expect(adapter.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } })
|
||||
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
|
||||
expect(node).toMatchObject({ callView: { title: '回声' }, resultView: null })
|
||||
})
|
||||
|
||||
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
|
||||
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], 50, [resultView] as never)
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
94
packages/client/runtime/tests/history-fold.spec.ts
Normal file
94
packages/client/runtime/tests/history-fold.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
|
||||
import { ev } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
|
||||
|
||||
describe('projectConversationHistory', () => {
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
ev.user(1, 'b'),
|
||||
at(2, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
at(3, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: 2, end: 1 },
|
||||
sourceEventSeqs: [2, 1],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'summary 2' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
|
||||
id: context.id,
|
||||
parentId: context.parentId,
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
|
||||
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
|
||||
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects assistant timing and the active request header from history', () => {
|
||||
const projection = projectConversationHistory([
|
||||
ev.stepStart(0, 1, 2),
|
||||
at(1, { type: 'request/header', data: {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'first' },
|
||||
tools: [],
|
||||
},
|
||||
} }),
|
||||
ev.chunkStart(2, 1, 2),
|
||||
ev.chunkText(3, 1, 'token', 2),
|
||||
ev.assistant(4, 1, 'done', 2),
|
||||
ev.stepStart(5, 2, 1),
|
||||
ev.chunkText(6, 2, 'next', 1),
|
||||
ev.assistant(7, 2, 'next done', 1),
|
||||
].map(event => ({ event })))
|
||||
|
||||
expect(projection.eventNodes[0]).toMatchObject({
|
||||
kind: 'assistant',
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_000,
|
||||
firstTokenTime: 1_700_000_000_003,
|
||||
completedTime: 1_700_000_000_004,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
|
||||
expect(projection.eventNodes.at(-1)).toMatchObject({
|
||||
timing: {
|
||||
stepStartTime: 1_700_000_000_005,
|
||||
firstTokenTime: 1_700_000_000_006,
|
||||
completedTime: 1_700_000_000_007,
|
||||
},
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -206,6 +206,49 @@ describe('list lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('returns bounded Host results and forwards the caller signal', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSearch = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
|
||||
hasMore: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
|
||||
hasMore: true,
|
||||
},
|
||||
})
|
||||
expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
|
||||
expect(api.lastSearchSignal).toBe(signal)
|
||||
})
|
||||
|
||||
it('preserves business errors and folds transport failures', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onSearch = () => Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'index unavailable',
|
||||
details: {},
|
||||
}))
|
||||
const signal = new AbortController().signal
|
||||
await expect(manager.search('first', signal)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'index unavailable' },
|
||||
})
|
||||
|
||||
api.onSearch = () => Promise.reject(new Error('wire down'))
|
||||
await expect(manager.search('second', signal)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'wire down' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('host frame routing', () => {
|
||||
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -85,6 +85,68 @@ describe('inspectRequests', () => {
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('preserves a standalone compaction owner without widening assistant turns', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
at(1, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'standalone summary' }],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
}),
|
||||
at(2, 'compact/end', { turn: null }),
|
||||
at(3, 'step/start', { turn: 2, step: 1 }),
|
||||
]))
|
||||
|
||||
const [compaction, assistant] = snapshot.requests
|
||||
expect(compaction).toMatchObject({
|
||||
purpose: 'compaction',
|
||||
turn: null,
|
||||
step: 0,
|
||||
status: 'complete',
|
||||
})
|
||||
expect(assistant).toMatchObject({
|
||||
purpose: 'assistant',
|
||||
turn: 2,
|
||||
step: 1,
|
||||
status: 'running',
|
||||
})
|
||||
if (assistant?.purpose === 'assistant') {
|
||||
const turn: number = assistant.turn
|
||||
expect(turn).toBe(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
at(1, 'session/end-seed', {}),
|
||||
at(2, 'compact/start', { turn: null }),
|
||||
at(3, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'replacement summary' }],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
}),
|
||||
at(4, 'compact/end', { turn: null }),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 0,
|
||||
status: 'error',
|
||||
completedAt: 1_700_000_000_001,
|
||||
error: 'Compaction was interrupted before completion.',
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 2,
|
||||
status: 'complete',
|
||||
completedAt: 1_700_000_000_004,
|
||||
summary: [{ type: 'text', text: 'replacement summary' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('captures schemas for nested tool dispatches from the active request header', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'request/header', {
|
||||
@@ -159,6 +221,33 @@ describe('inspectRequests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps provider credential fragments out of projected request errors', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'turn/end', {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error',
|
||||
step: 1,
|
||||
failure: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 2, step: 1 }),
|
||||
at(3, 'turn/end', {
|
||||
turn: 2,
|
||||
reason: { kind: 'error', step: 1, message: 'plugin exploded' },
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{ status: 'error', error: 'API key is invalid' },
|
||||
{ status: 'error', error: 'plugin exploded' },
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
@@ -179,6 +268,7 @@ describe('inspectRequests', () => {
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas).toEqual(new Map())
|
||||
expect(snapshot.requests[0]?.prompt?.tools).toEqual([])
|
||||
const [request] = snapshot.requests
|
||||
expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
@@ -161,6 +162,259 @@ describe('live event path', () => {
|
||||
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const retryTurn = [
|
||||
ev.turnStart(6, 1),
|
||||
ev.user(7, '请重试'),
|
||||
ev.stepStart(8, 1),
|
||||
ev.chunkStart(9, 1),
|
||||
ev.chunkText(10, 1, '不完整回复'),
|
||||
ev.stepEnd(11, 1),
|
||||
ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
|
||||
at(13, {
|
||||
type: 'turn/end',
|
||||
data: {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error', step: 0,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
|
||||
ev.stepStart(15, 2),
|
||||
ev.assistant(16, 2, '完整回复'),
|
||||
ev.stepEnd(17, 2),
|
||||
ev.turnEnd(18, 2),
|
||||
]
|
||||
for (const event of retryTurn.slice(0, 7)) feed(event)
|
||||
|
||||
let snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 450,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
})
|
||||
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
|
||||
|
||||
for (const event of retryTurn.slice(7)) feed(event)
|
||||
snapshot = session.getSnapshot()
|
||||
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
|
||||
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
|
||||
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
|
||||
await replay.session.open()
|
||||
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
|
||||
expect(replay.session.getSnapshot().partial).toBeNull()
|
||||
})
|
||||
|
||||
it('projects unretried terminal failures at turn/end and reproduces them from history', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
const failedTurns = [
|
||||
ev.turnStart(6, 1),
|
||||
ev.user(7, '鉴权失败'),
|
||||
at(8, {
|
||||
type: 'turn/end',
|
||||
data: {
|
||||
turn: 1,
|
||||
reason: {
|
||||
kind: 'error',
|
||||
step: 0,
|
||||
failure: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ev.turnStart(9, 2),
|
||||
ev.user(10, '内部失败'),
|
||||
at(11, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } },
|
||||
}),
|
||||
]
|
||||
for (const event of failedTurns) feed(event)
|
||||
|
||||
const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
|
||||
expect(errors).toMatchObject([
|
||||
{ seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
|
||||
{ seq: 11, turn: 2, step: 1, message: 'plugin exploded' },
|
||||
])
|
||||
expect('code' in errors[1]!).toBe(false)
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
|
||||
await replay.session.open()
|
||||
expect(replay.session.getSnapshot().nodes).toEqual(session.getSnapshot().nodes)
|
||||
})
|
||||
|
||||
it('rejects retry payloads outside the producer contract without retracting the current partial', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.chunkStart(7, 1))
|
||||
feed(ev.chunkText(8, 1, '仍在生成'))
|
||||
const valid = {
|
||||
turn: 1, step: 0,
|
||||
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
|
||||
retry: 1, maxRetries: 2, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary failure' },
|
||||
}
|
||||
const invalid = [
|
||||
{ ...valid, turn: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, step: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, provider: '' },
|
||||
{ ...valid, policyKey: '' },
|
||||
{ ...valid, retry: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...valid, delayMs: -1 },
|
||||
{ ...valid, delayMs: Number.POSITIVE_INFINITY },
|
||||
{ ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 },
|
||||
{ ...valid, failure: { ...valid.failure, message: '' } },
|
||||
{ ...valid, failure: { ...valid.failure, code: '' } },
|
||||
{ ...valid, failure: { ...valid.failure, status: '429' } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 99 } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 429.5 } },
|
||||
{ ...valid, failure: { ...valid.failure, status: 600 } },
|
||||
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } },
|
||||
{ ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } },
|
||||
{ ...valid, failure: { ...valid.failure, requestId: 1 } },
|
||||
{ ...valid, failure: { ...valid.failure, requestId: '' } },
|
||||
]
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
for (const [index, data] of invalid.entries()) {
|
||||
feed(at(9 + index, { type: 'llm/retry', data }))
|
||||
}
|
||||
expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([])
|
||||
expect(errorSpy).toHaveBeenCalledTimes(invalid.length)
|
||||
expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9')
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts complete retry payloads at the producer field boundaries', async () => {
|
||||
const { session } = await opened()
|
||||
session.handleMuxEnvelope('r' as never, {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: at(6, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: Number.MAX_SAFE_INTEGER,
|
||||
step: Number.MAX_SAFE_INTEGER,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: Number.MAX_SAFE_INTEGER,
|
||||
maxRetries: Number.MAX_SAFE_INTEGER,
|
||||
delayMs: MAX_TIMER_DELAY_MS,
|
||||
failure: {
|
||||
code: 'RATE_LIMIT',
|
||||
message: 'provider busy',
|
||||
status: 599,
|
||||
providerRetryAfterMs: Number.MIN_VALUE,
|
||||
requestId: 'req-1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
retry: Number.MAX_SAFE_INTEGER,
|
||||
delayMs: MAX_TIMER_DELAY_MS,
|
||||
failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
feed(at(6, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 1, step: 0,
|
||||
provider: 'fake', mode: 'always', policyKey: 'fake-always',
|
||||
retry: 3, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'retry forever' },
|
||||
},
|
||||
}))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
mode: 'always',
|
||||
retry: 3,
|
||||
})
|
||||
|
||||
feed(at(7, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 2, step: 0,
|
||||
provider: 'fake', mode: 'always', policyKey: 'fake-always',
|
||||
retry: 4, maxRetries: 4, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'unexpected maximum' },
|
||||
},
|
||||
}))
|
||||
feed(at(8, {
|
||||
type: 'llm/retry',
|
||||
data: {
|
||||
turn: 2, step: 0,
|
||||
provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown',
|
||||
retry: 4, delayMs: 500,
|
||||
failure: { code: 'TRANSPORT', message: 'unknown mode' },
|
||||
},
|
||||
}))
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1)
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['aborted', 'disposed'] as const)(
|
||||
'marks a scheduled retry as cancelled when its failed turn ends %s',
|
||||
async (reason) => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.retry(7, 1))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'scheduled',
|
||||
})
|
||||
feed(ev.turnEnd(8, 1, reason))
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'cancelled',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
@@ -168,7 +422,7 @@ describe('live event path', () => {
|
||||
feed(ev.user(7, '要被打断的'))
|
||||
feed(ev.chunkStart(8, 1))
|
||||
feed(ev.chunkText(9, 1, '说到一半'))
|
||||
feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives
|
||||
feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
const frozen = snapshot.nodes.at(-1)
|
||||
@@ -187,7 +441,7 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().runningCalls).toEqual([])
|
||||
// Second call never resolves: turn/end freezes it as an error card.
|
||||
feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
|
||||
feed(ev.turnEnd(10, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(10, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.runningCalls).toEqual([])
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({
|
||||
@@ -195,6 +449,45 @@ describe('live event path', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps compacted history and adds one marker, live and on replay alike', async () => {
|
||||
// A landed compaction must not erase conversation the reader already saw:
|
||||
// the shadowed messages stay at their own log positions and the checkpoint
|
||||
// contributes one marker after them.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.compactSummary(6, '压缩摘要', 1, 3))
|
||||
feed(ev.compactCheckpoint(7, 6, 1, 3))
|
||||
const live = session.getSnapshot().nodes
|
||||
expect(live.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
|
||||
expect(live.at(-1)).toMatchObject({ kind: 'compaction', summary: '压缩摘要' })
|
||||
|
||||
const replayed = await opened([
|
||||
...plainTurn(0, 0, 'a', 'b'),
|
||||
ev.compactSummary(6, '压缩摘要', 1, 3),
|
||||
ev.compactCheckpoint(7, 6, 1, 3),
|
||||
])
|
||||
expect(replayed.session.getSnapshot().nodes).toEqual(live)
|
||||
})
|
||||
|
||||
it('merges an interrupted frozen node by seq into the log-ordered transcript', async () => {
|
||||
// The transcript array is seq-monotonic, so the frozen node's fractional
|
||||
// seq lands it exactly where it happened — including after a compaction
|
||||
// checkpoint whose own seq is higher than the range it shadowed.
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.compactSummary(6, '压缩摘要', 1, 3))
|
||||
feed(ev.compactCheckpoint(7, 6, 1, 3))
|
||||
feed(ev.turnStart(8, 1))
|
||||
feed(ev.user(9, '压缩后的提问'))
|
||||
feed(ev.chunkStart(10, 1))
|
||||
feed(ev.chunkText(11, 1, '说到一半'))
|
||||
feed(ev.turnEnd(12, 1, 'aborted'))
|
||||
expect(session.getSnapshot().nodes.map(n => n.kind)).toEqual([
|
||||
'user', 'assistant', 'compaction', 'user', 'assistant',
|
||||
])
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ interrupted: true })
|
||||
})
|
||||
|
||||
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
|
||||
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
|
||||
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
|
||||
@@ -226,6 +519,30 @@ describe('paging', () => {
|
||||
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
|
||||
})
|
||||
|
||||
it('renders a page whose checkpoint shadows seqs below the window head, logging nothing', async () => {
|
||||
// Pagination no longer spends maxMessages quota on replacement copies, so a
|
||||
// page can carry a compaction checkpoint whose surfaceOp.start lies outside
|
||||
// the window. The old surface fold rejected that range and degraded with a
|
||||
// console error; the log-ordered transcript has no range to resolve.
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse([
|
||||
ev.compactSummary(80, '窗外范围的摘要', 3, 40),
|
||||
ev.compactCheckpoint(81, 80, 3, 40),
|
||||
ev.user(82, '压缩后的新问题'),
|
||||
], true)
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
await session.open()
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.openState).toBe('open')
|
||||
expect(snapshot.nodes.map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
|
||||
expect(snapshot.nodes[0]).toMatchObject({ summary: '窗外范围的摘要' })
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
@@ -529,7 +846,7 @@ describe('remaining branches', () => {
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.chunkStart(7, 1)) // empty text block only, no delta
|
||||
feed(ev.turnEnd(8, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(8, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
|
||||
@@ -543,7 +860,7 @@ describe('remaining branches', () => {
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
|
||||
feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
|
||||
feed(ev.turnEnd(9, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(9, 1, 'aborted'))
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
|
||||
@@ -637,7 +954,7 @@ describe('remaining branches', () => {
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
|
||||
feed(ev.turnEnd(8, 1, 'cancelled'))
|
||||
feed(ev.turnEnd(8, 1, 'aborted'))
|
||||
const frozen = session.getSnapshot().nodes.at(-1)
|
||||
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
|
||||
})
|
||||
|
||||
@@ -69,6 +69,29 @@ describe('list store projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('delegates transient content search without changing the list snapshot', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const before = b.svc.list.getSnapshot()
|
||||
b.api.onSearch = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(b.svc.search('needle', signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 's1', snippet: 'matching excerpt' }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(b.api.lastSearchSignal).toBe(signal)
|
||||
expect(b.svc.list.getSnapshot()).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope tree', () => {
|
||||
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
|
||||
const b = bench()
|
||||
|
||||
@@ -41,8 +41,8 @@ interface Bench {
|
||||
|
||||
async function boot(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
ctx.plugin(SlotsService)
|
||||
await ctx.fiber.await()
|
||||
const fiber = ctx.plugin(SlotsService)
|
||||
await fiber
|
||||
// Service accessor (ctx.get reads the reflect store, which Service-class
|
||||
// plugins do not write; the accessor is the product path).
|
||||
const svc = ctx.slots
|
||||
|
||||
418
packages/client/runtime/tests/transcript-adapter.spec.ts
Normal file
418
packages/client/runtime/tests/transcript-adapter.spec.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* TranscriptAdapter over the raw append-only window: log-ordered projection of
|
||||
* append-origin events, one marker per landed compaction, replacement copies
|
||||
* hidden, command-lifecycle folding, node/array identity, call pairing, and
|
||||
* host-provided wire views.
|
||||
*/
|
||||
|
||||
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts'
|
||||
import { ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
|
||||
|
||||
/** A `compact/summary` provenance event (log-only, no surfaceOp). */
|
||||
function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent {
|
||||
return at(seq, {
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
summary,
|
||||
shadowedRange: { start: 1, end: 3 },
|
||||
shadowedSeqs: [1, 3],
|
||||
shadowedTokenCount: 100,
|
||||
provider: 'fake',
|
||||
model: 'compact-1',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** The replacement user message a compaction backend lands (the checkpoint). */
|
||||
function checkpoint(
|
||||
seq: number,
|
||||
summarySeq: number,
|
||||
{ start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: {
|
||||
start?: number
|
||||
end?: number
|
||||
sourceEventSeqs?: number[]
|
||||
} = {},
|
||||
): SessionEvent {
|
||||
return at(seq, {
|
||||
type: 'user/message',
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs,
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>model only</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('TranscriptAdapter', () => {
|
||||
it('projects a window starting past seq 0 at its own log positions', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(100, 5, '偏移问', '偏移答'))
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
|
||||
})
|
||||
|
||||
it('appends incrementally keeping old node references (materialize-once identity)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'))
|
||||
const first = adapter.nodes()
|
||||
adapter.append(ev.user(6, '追加'))
|
||||
const second = adapter.nodes()
|
||||
expect(second).toHaveLength(3)
|
||||
expect(second[0]).toBe(first[0])
|
||||
expect(second[1]).toBe(first[1])
|
||||
expect(second).not.toBe(first) // a real change swaps the array
|
||||
})
|
||||
|
||||
it('keeps the array reference across a chunk storm and swaps it when a node lands', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'))
|
||||
const settled = adapter.nodes()
|
||||
adapter.append(ev.chunkStart(6, 1))
|
||||
expect(adapter.nodes()).toBe(settled)
|
||||
adapter.append(ev.chunkText(7, 1, '流式'))
|
||||
expect(adapter.nodes()).toBe(settled)
|
||||
adapter.append(ev.assistant(8, 1, '流式完成'))
|
||||
const finalized = adapter.nodes()
|
||||
expect(finalized).not.toBe(settled)
|
||||
expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 })
|
||||
})
|
||||
|
||||
it('materializes every append-origin variant with field mapping', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '用户'),
|
||||
ev.assistant(1, 0, '助手'),
|
||||
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', '结果'),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
|
||||
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('skips events core does not call surface-eligible, marker or not', () => {
|
||||
// The transcript is the append-origin surface, so log-only events (a chunk,
|
||||
// a turn boundary, a compact/* provenance record) and a future type core
|
||||
// has not admitted contribute no node.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.turnStart(0, 1),
|
||||
at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }),
|
||||
compactSummary(2),
|
||||
ev.user(3, '唯一的一条'),
|
||||
ev.turnEnd(4, 1),
|
||||
])
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]])
|
||||
})
|
||||
|
||||
describe('compaction markers', () => {
|
||||
it('keeps the original messages and full tool output, hiding replacement copies', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '原始问题'),
|
||||
ev.assistant(1, 0, '原始回答'),
|
||||
ev.toolCall(4, 0, 'c1', 'echo', '{}'),
|
||||
ev.toolResult(5, 0, 'c1', '完整工具输出'),
|
||||
// A pruned tool/result copy: rewrites one node for the model, marks nothing.
|
||||
at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: {
|
||||
turn: 0, step: 0,
|
||||
message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }),
|
||||
} }),
|
||||
compactSummary(7),
|
||||
checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }),
|
||||
// A regenerated assistant/message: also a silent model-only rewrite.
|
||||
at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: {
|
||||
turn: 0, step: 0,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '通用 replacement 副本' }],
|
||||
source: { kind: 'model', ...{ provider: 'x', model: 'copy' } },
|
||||
}),
|
||||
} }),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([
|
||||
['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8],
|
||||
])
|
||||
expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] })
|
||||
expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' })
|
||||
})
|
||||
|
||||
it('adds one marker per landed compaction, in log order', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, 'a'),
|
||||
compactSummary(1, [{ type: 'text', text: 'first' }]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
ev.user(3, 'b'),
|
||||
compactSummary(4, [{ type: 'text', text: 'second' }]),
|
||||
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
|
||||
])
|
||||
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' },
|
||||
{ kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' },
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the marker when the shadowed range is outside the window and logs nothing', () => {
|
||||
// The pagination hole A1 left open: quota is no longer spent on
|
||||
// replacement copies, so a page can carry a checkpoint whose
|
||||
// surfaceOp.start lies below the window head. The old surface fold threw
|
||||
// on the missing range and degraded with a console error; a log-ordered
|
||||
// projection has no range to resolve.
|
||||
const adapter = new TranscriptAdapter()
|
||||
const noise = { error: console.error, warn: console.warn }
|
||||
const logged: unknown[] = []
|
||||
console.error = (...args: unknown[]) => logged.push(args)
|
||||
console.warn = (...args: unknown[]) => logged.push(args)
|
||||
try {
|
||||
adapter.reset([
|
||||
compactSummary(80, [{ type: 'text', text: '窗外范围' }]),
|
||||
checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }),
|
||||
ev.user(82, '压缩后的新问题'),
|
||||
])
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' })
|
||||
} finally {
|
||||
console.error = noise.error
|
||||
console.warn = noise.warn
|
||||
}
|
||||
expect(logged).toEqual([])
|
||||
})
|
||||
|
||||
it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => {
|
||||
// A session-reference card carries the same plugin source shape; only the
|
||||
// replacement marker makes an event a checkpoint.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '注入的上下文' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
|
||||
})
|
||||
|
||||
it('ignores a foreign plugin s replacement user/message', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '保留'),
|
||||
at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({
|
||||
content: [{ type: 'text', text: '别的插件重写' }],
|
||||
source: { kind: 'plugin', plugin: 'not-compact' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['absent provenance', undefined],
|
||||
['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])],
|
||||
['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])],
|
||||
['an empty summary array', compactSummary(1, [])],
|
||||
['a non-array summary', compactSummary(1, 'plain string')],
|
||||
])('degrades %s to a non-expandable marker', (_label, summary) => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
...(summary === undefined ? [] : [summary]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toEqual([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => {
|
||||
// ContentBlock is merge-extensible and the payload type is ContentBlock[],
|
||||
// so a non-text block must not discard recoverable text beside it.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toEqual([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' },
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves the summary null when the checkpoint records no provenance at all', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([at(2, {
|
||||
type: 'user/message',
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<context_checkpoint>x</context_checkpoint>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
})])
|
||||
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }])
|
||||
})
|
||||
|
||||
it('skips a non-summary provenance seq before reaching the real one', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '被压缩的问题'),
|
||||
at(1, { type: 'compact/start', data: { turn: 0 } }),
|
||||
compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]),
|
||||
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }),
|
||||
])
|
||||
expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' })
|
||||
})
|
||||
|
||||
it('resolves the summary once an older page supplies the provenance', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] })
|
||||
adapter.reset([landed])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null })
|
||||
adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' })
|
||||
})
|
||||
|
||||
it('creates the marker on the live append path', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b'))
|
||||
adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }]))
|
||||
adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] }))
|
||||
const nodes = adapter.nodes()
|
||||
// The compacted history is still there; the marker is one more row after it.
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]])
|
||||
expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' })
|
||||
})
|
||||
})
|
||||
|
||||
it('returns call:null for a tool-result whose call fell outside the window', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
|
||||
})
|
||||
|
||||
it('materializes a tool-result error field when present', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
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' },
|
||||
} }),
|
||||
])
|
||||
expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
|
||||
})
|
||||
|
||||
it('attaches wire views to the materialized result node', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
|
||||
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
|
||||
adapter.reset([
|
||||
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
|
||||
ev.toolResult(1, 1, 'c1', 'listing'),
|
||||
], [callView, resultView] as never)
|
||||
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' },
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches views on the live append path and defaults to null without views', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument
|
||||
adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
|
||||
adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
|
||||
expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callView: { title: '回声' }, resultView: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
|
||||
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never)
|
||||
expect(adapter.nodes()[0]).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 TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '先说话'),
|
||||
ev.commandRun(1, 'cmd-1', 'plan'),
|
||||
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
|
||||
ev.assistant(3, 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 TranscriptAdapter()
|
||||
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')])
|
||||
expect(adapter.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 TranscriptAdapter()
|
||||
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')])
|
||||
expect(adapter.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 TranscriptAdapter()
|
||||
adapter.reset(plainTurn(0, 0, 'q', 'a'))
|
||||
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
|
||||
const running = adapter.nodes().find(n => n.kind === 'command')
|
||||
expect(running).toMatchObject({ outcome: null })
|
||||
adapter.append(ev.commandDone(7, 'cmd-4'))
|
||||
const settled = adapter.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 transcript node', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')])
|
||||
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
|
||||
})
|
||||
|
||||
it('renders the /compact row alongside the marker its own command produced', () => {
|
||||
// The row that reports the compaction is a command node; dropping command
|
||||
// folding would delete it together with every other slash-command row.
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '压缩前的问题'),
|
||||
ev.commandRun(1, 'cmd-compact', 'compact'),
|
||||
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
|
||||
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
|
||||
ev.commandDone(4, 'cmd-compact', 'success', '已压缩'),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
|
||||
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -183,6 +183,12 @@ describe('WorkspacesService', () => {
|
||||
|
||||
// Unknown workspace fails loud instead of silently creating in nowhere.
|
||||
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
|
||||
|
||||
// An archived blank is never reused: no surface can show it, so New
|
||||
// Session mints a fresh one for alpha instead.
|
||||
await workspaces.archiveSession(sid('s-blank'))
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') }))
|
||||
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2')
|
||||
})
|
||||
|
||||
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
|
||||
@@ -285,6 +291,84 @@ describe('WorkspacesService', () => {
|
||||
}))
|
||||
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
|
||||
})
|
||||
|
||||
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
{ sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false },
|
||||
{ sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false },
|
||||
],
|
||||
}) as never)
|
||||
await sessions.refresh()
|
||||
sessions.open(sid('s-open'))
|
||||
|
||||
// Archiving a non-current session installs the unary echo and keeps the selection.
|
||||
await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined()
|
||||
expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }])
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
|
||||
expect(sessions.list.getSnapshot().current).toBe('s-open')
|
||||
|
||||
// Archiving the current session clears it into the New Session view state.
|
||||
api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
|
||||
await workspaces.archiveSession(sid('s-open'))
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
|
||||
expect(sessions.list.getSnapshot().current).toBeUndefined()
|
||||
|
||||
// A Host failure leaves the set and the selection untouched.
|
||||
api.onWorkspaceArchiveSession = () => Promise.resolve(err({
|
||||
code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') },
|
||||
}))
|
||||
await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/)
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
|
||||
|
||||
// The changed frame and the list baseline both re-install the full set.
|
||||
workspaces.handleHostEnvelope({
|
||||
rpcId: 'frame' as never,
|
||||
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] },
|
||||
} as never)
|
||||
// Frame installs ride the notifier's microtask batch before projecting.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never)
|
||||
await workspaces.refresh()
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
|
||||
})
|
||||
|
||||
it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
await sessions.refresh()
|
||||
sessions.open(sid('s-open'))
|
||||
|
||||
// A stale baseline is in flight (older, empty set) when another tab's
|
||||
// archive frame lands: the frame clears the current selection and its
|
||||
// set survives the baseline's later resolution.
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
const hydration = workspaces.refresh()
|
||||
workspaces.handleHostEnvelope({
|
||||
rpcId: 'frame' as never,
|
||||
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] },
|
||||
} as never)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(sessions.list.getSnapshot().current).toBeUndefined()
|
||||
gate.resolve(ok({ items: [], archivedSessionIds: [] }))
|
||||
await hydration
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
|
||||
// The next (fresh) baseline is authoritative again.
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never)
|
||||
await workspaces.refresh()
|
||||
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('startInitialSelection', () => {
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
@@ -35,6 +38,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-retry"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user