Merge remote-tracking branch 'upstream/master' into feat/web-workspace-file-links
# Conflicts: # apps/web/tsconfig.json # packages/client/connection/README.i18n.yaml # packages/client/connection/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/ToolRow.module.css # packages/client/ui-conversation/src/client/chat/chat-flow.ts # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/host/apiproxy/src/native-path-opener.ts # tsconfig.host.json
This commit is contained in:
@@ -51,7 +51,7 @@ Non-negotiables across the layers:
|
||||
|
||||
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
|
||||
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
|
||||
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`.
|
||||
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
|
||||
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
|
||||
|
||||
## Directory regime (plugin packages)
|
||||
@@ -60,16 +60,16 @@ One UI feature = one plugin package (`src/client/` browser half). A multi-domain
|
||||
|
||||
## Styling
|
||||
|
||||
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. In short: design tokens live in `web-ui/src/style/global.css` (`:root` light values, `[data-theme='dark']` overrides); component CSS references tokens only — no literal color values. CSS Modules + `clsx`; no component library, no tailwind ([framework ruling](../../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)). Product copy is Chinese; code comments are English.
|
||||
[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Product copy is Chinese; code comments are English.
|
||||
|
||||
## Testing and coverage
|
||||
|
||||
The GUI test structure (three tiers, lane map) is settled in the [GUI testing system note](../../.agents/notes/implemented/process/2026-07-20-gui-testing-system.md); repo-wide policy in [docs/testing.md](../../docs/testing.md).
|
||||
|
||||
- **Both client packages are inside the per-file 100% coverage gate** (`pnpm run test:coverage`). `web-runtime` is covered by node-env object/protocol suites; `web-ui` rides the jsdom lane. Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
|
||||
- **web-ui specs are end-to-end behavior checks, not unit tests.** A jsdom spec renders the component with realistic props (or a driven fixture runtime) and asserts what the user would see — never class names, hook internals, or render counts. Components are consumables: behavior-shaped specs survive a rewrite, implementation-shaped specs don't.
|
||||
- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line — the shared config stays node-env. Start a new spec from an existing one (`web-ui/tests/tool-card.spec.tsx` is a good template).
|
||||
- **Each tier asserts its own layer.** Data-layer semantics (state machines, wire shapes, reference stability) belong to the `web-runtime` and `apiproxy` suites — don't re-assert them from component specs.
|
||||
- Client source packages are inside the per-file 100% coverage gate (`pnpm run test:coverage`). Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
|
||||
- Component specs render with realistic props or a driven fixture runtime and assert user-visible behavior, not class names, hook internals, or render counts.
|
||||
- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line; the shared config stays node-env.
|
||||
- Each tier asserts its own layer. Data-layer semantics belong to the runtime and host suites; component specs cover presentation behavior.
|
||||
|
||||
## Before you push: the local check ladder
|
||||
|
||||
@@ -88,7 +88,7 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the l
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
|
||||
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
|
||||
## New component checklist
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
|
||||
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8
|
||||
README.md: b950772d4cad6d873426f8aee6416fa56afca2ee
|
||||
README.zh.md: 8f1f7f46777b7037e8baa04c9ec16ef74ffd478d
|
||||
|
||||
@@ -2,33 +2,38 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-<name>`.
|
||||
The browser side of the dsh web GUI: shell boot, browser-host communication, shared UI services, and feature plugins. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All except `test-runtime` are **product** packages named `@deepseek-ai/dsh-client-<name>`.
|
||||
|
||||
| Package | Role | ctx key / slot |
|
||||
|---|---|---|
|
||||
| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) |
|
||||
| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) |
|
||||
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
|
||||
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
|
||||
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
|
||||
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
|
||||
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
|
||||
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
|
||||
| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) |
|
||||
| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` |
|
||||
| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) |
|
||||
| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) |
|
||||
| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) |
|
||||
| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) |
|
||||
| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` |
|
||||
| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` |
|
||||
| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) |
|
||||
| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) |
|
||||
| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` |
|
||||
| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) |
|
||||
| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) |
|
||||
| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) |
|
||||
| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) |
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| [`web/`](web/README.md) | Boots the browser shell from the client entry graph. |
|
||||
| [`modules/`](modules/README.md) | Loads browser-side client modules. |
|
||||
| [`web-react/`](web-react/README.md) | Connects the shell runtime to React rendering. |
|
||||
| [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery. |
|
||||
| [`runtime/`](runtime/README.md) | Provides shared client services for sessions, workspaces, and UI composition. |
|
||||
| [`hmr/`](hmr/README.md) | Refreshes client plugins during development. |
|
||||
| [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries. |
|
||||
| [`schema-form/`](schema-form/README.md) | Provides schema-backed draft handling for settings editors. |
|
||||
| [`test-runtime/`](test-runtime/README.md) | Provides shared repository test support for client feature packages. |
|
||||
| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. |
|
||||
| [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. |
|
||||
| [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. |
|
||||
| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. |
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
|
||||
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
|
||||
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
|
||||
| [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. |
|
||||
| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. |
|
||||
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
|
||||
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
|
||||
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
|
||||
|
||||
Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.
|
||||
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.
|
||||
|
||||
@@ -2,33 +2,38 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-<name>`。
|
||||
dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 UI 服务和特性插件。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。除 `test-runtime` 外,均为命名成 `@deepseek-ai/dsh-client-<name>` 的**产品**包。
|
||||
|
||||
| 包 | 角色 | ctx 键/slot |
|
||||
|---|---|---|
|
||||
| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) |
|
||||
| `modules/` | 客户端模块系统:Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) |
|
||||
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
|
||||
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
|
||||
| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
|
||||
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
|
||||
| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` |
|
||||
| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
|
||||
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` |
|
||||
| `ui-primitives/` | 纯 React 原子:图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) |
|
||||
| `ui-layout/` | shell 三栏 AppFrame;声明 `sidebar`/`conversation`/`details`/`conversation.empty` | `ctx.layout` |
|
||||
| `ui-sidebar/` | 侧栏 shell:Workspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | (slot 宿主) |
|
||||
| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces`、`conversation.hero.workspace`) |
|
||||
| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | (slot 宿主) |
|
||||
| `ui-trajectory/` | Trajectory/Waterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view`) |
|
||||
| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` |
|
||||
| `ui-slash/` | 输入触发流水线:光标下的 `/` 与 `@` 检测、分组候选菜单、源名册 | `ctx.slash` |
|
||||
| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash`) |
|
||||
| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash`) |
|
||||
| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` |
|
||||
| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer`) |
|
||||
| `ui-settings/` | 设置 shell:触发 chrome + 模态面板;声明 `settings.*` slot | (slot 宿主) |
|
||||
| `ui-settings-general/` | 设置的无主文案:chrome 内容 + General 分区骨架 | (填充 `settings.*`) |
|
||||
| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section`) |
|
||||
| 包 | 目的 |
|
||||
|---|---|
|
||||
| [`web/`](web/README.md) | 从客户端条目图启动浏览器 shell。 |
|
||||
| [`modules/`](modules/README.md) | 加载浏览器侧客户端模块。 |
|
||||
| [`web-react/`](web-react/README.md) | 连接 shell 运行时与 React 渲染。 |
|
||||
| [`connection/`](connection/README.md) | 维护浏览器与宿主之间的 RPC 通信和事件传递。 |
|
||||
| [`runtime/`](runtime/README.md) | 为会话、Workspace 和 UI 组合提供共享客户端服务。 |
|
||||
| [`hmr/`](hmr/README.md) | 在开发期间刷新客户端插件。 |
|
||||
| [`locale/`](locale/README.md) | 提供本地化偏好与消息词典。 |
|
||||
| [`schema-form/`](schema-form/README.md) | 为设置编辑器提供 schema 驱动的草稿处理。 |
|
||||
| [`test-runtime/`](test-runtime/README.md) | 为客户端特性包提供共享的仓库测试支持。 |
|
||||
| [`ui-slots/`](ui-slots/README.md) | 定义 UI 特性注册和组合扩展 slot 的方式。 |
|
||||
| [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 |
|
||||
| [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 |
|
||||
| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 |
|
||||
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 |
|
||||
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
|
||||
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
|
||||
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
|
||||
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 |
|
||||
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
|
||||
| [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 |
|
||||
| [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 |
|
||||
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
|
||||
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
|
||||
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
|
||||
|
||||
特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。
|
||||
每个子文档负责自身的契约和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
|
||||
README.zh.md: f36cb4c4c6856089751e4492eb6e4b8e22abde56
|
||||
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
|
||||
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
## Keyless fixture
|
||||
## `/api` WebSocket downlinks
|
||||
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
|
||||
`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -22,5 +22,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
|
||||
- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.
|
||||
- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path.
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## 无密钥 fixture
|
||||
## `/api` WebSocket 下行
|
||||
|
||||
fixture 页面不由任何 host 提供,因此没有工作区文件端口注入其中,`ConnectionHandle.fileUrl` 应答 `undefined`——文件路径行会回退到 Host 打开器,而不是打开一个空标签页。
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
|
||||
`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` text message;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -24,5 +22,4 @@ fixture 页面不由任何 host 提供,因此没有工作区文件端口注入
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
|
||||
- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。
|
||||
- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-connection",
|
||||
"description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)",
|
||||
"description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -34,15 +34,14 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
@@ -52,6 +51,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/ws": "^8.18.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* The /api URL prefix — single source for both halves of the web transport.
|
||||
* The node half registers this prefix on the web server; browser-side path
|
||||
* literals currently live in the apiproxy client layer (out of scope here).
|
||||
* The node half registers this prefix on the web server; both halves share the
|
||||
* event paths below for the browser WebSocket downlinks.
|
||||
*/
|
||||
|
||||
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
|
||||
export const API_PATH = '/api'
|
||||
|
||||
/** Browser mux-frame WebSocket pathname. */
|
||||
export const MUX_EVENTS_PATH = `${API_PATH}/events.mux`
|
||||
|
||||
/** Browser host-frame WebSocket pathname. */
|
||||
export const HOST_EVENTS_PATH = `${API_PATH}/events.host`
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* the attacker's domain while the socket reaches this server) and cross-site
|
||||
* requests fired from a malicious page. The Host fence binds every request,
|
||||
* browser-looking or not: over plain HTTP a browser attaches neither Origin
|
||||
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
|
||||
* nor Fetch-Metadata to reads (images and navigations — those
|
||||
* headers go only to trustworthy destinations), so an unmarked request may
|
||||
* still be a rebound browser read and Host is the one header rebinding cannot
|
||||
* forge. Non-browser and remote clients pass the same fence via loopback, the
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { isLoopbackHostname } from './loopback-hostname.ts'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
interface ApiTrustRequest {
|
||||
@@ -25,14 +26,6 @@ function header(headers: IncomingHttpHeaders, name: string): string | undefined
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
|
||||
function parseAuthority(authority: string): URL | undefined {
|
||||
try {
|
||||
@@ -104,7 +97,7 @@ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: read
|
||||
// fills Host from the URL it believes it is talking to, so a rebound page
|
||||
// carries the attacker's domain here even though the socket lands on this
|
||||
// server. There is no marker shortcut — a browser read over plain HTTP
|
||||
// (EventSource, images, navigations) arrives with neither Origin nor
|
||||
// (images and navigations) arrives with neither Origin nor
|
||||
// Fetch-Metadata, indistinguishable from curl, and its response is readable
|
||||
// by the rebound page.
|
||||
const host = header(request.headers, 'host')
|
||||
|
||||
@@ -12,10 +12,11 @@ export type {
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
@@ -33,6 +34,7 @@ export {
|
||||
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
|
||||
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
|
||||
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
@@ -126,7 +126,7 @@ export class ConnectionController {
|
||||
|
||||
try {
|
||||
// Strict readiness handshake (audit C2): describe proves unary reachability, onOpen
|
||||
// proves each SSE transport is established (response headers in, before any frame) —
|
||||
// proves each physical stream is established before any frame —
|
||||
// only then may onConnected fire, so the resync it triggers cannot outrun the
|
||||
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
||||
// (see ConnectionConfig.streamOpenTimeoutMs).
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
// Type-only: the brand constructor is host-side; the fixture casts at its
|
||||
// wire-fabrication boundary (the schema layer's one-cast-point posture).
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
@@ -167,7 +167,7 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
|
||||
{ lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
|
||||
{ lineNumber: 35, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 52, line: ' search={search}' },
|
||||
{ lineNumber: 73, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
{ lineNumber: 78, line: " yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -339,7 +339,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
|
||||
}
|
||||
|
||||
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
|
||||
* mixing reasoning blocks / tool call+result / steering / context. */
|
||||
* mixing reasoning blocks / tool call+result / context. */
|
||||
function buildAlphaLog(): SessionEvent[] {
|
||||
const events: Record<string, unknown>[] = []
|
||||
let time = Date.now() - 3_600_000
|
||||
@@ -358,8 +358,14 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
events.push({ seq, time: (time += 800), ...authored })
|
||||
return seq
|
||||
}
|
||||
// This resident history represents completed model requests, so retain the
|
||||
// route capacity that accompanied them just as the live prompt path does.
|
||||
push({
|
||||
type: 'request/context',
|
||||
data: { provider: 'deepseek-official', model: 'deepseek-v4-flash', contextWindow: 128_000 },
|
||||
})
|
||||
for (let turn = 0; turn < 60; turn++) {
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
const userSeq = push({
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`)),
|
||||
@@ -393,9 +399,6 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
}
|
||||
if (turn % 13 === 6) {
|
||||
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(text(`插话 ${turn}:fixture steering 消息。`)) } })
|
||||
}
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
|
||||
@@ -403,7 +406,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// stays presenter-less as the unknown fallback.
|
||||
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
|
||||
const callId = `fx-call-${turn}`
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
@@ -440,7 +443,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
+ 'await tools.read({ file_path: "notes/missing.txt" }).catch(() => "tolerated")\n'
|
||||
+ 'return { listing, demo }'
|
||||
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:run_code 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
@@ -685,7 +688,7 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
* Fixture parallel of the plan unit's double-event fold: `command/run`
|
||||
* records named `plan` set the wanted target (`off` → false, else true);
|
||||
* `plan/mode` commits and clears it. `wanted` is exposed for the prompt
|
||||
* boundary (the fixture's agent/step parallel).
|
||||
* boundary (the fixture's step/start parallel).
|
||||
*/
|
||||
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
|
||||
let active = false
|
||||
@@ -822,6 +825,62 @@ interface FixtureRequestContext {
|
||||
contextWindow?: number
|
||||
}
|
||||
|
||||
interface FixtureContextBreakdownProjection {
|
||||
systemTokens: number
|
||||
toolsTokens: number
|
||||
messageTokens: number
|
||||
}
|
||||
|
||||
/** Fixed token-meter heuristic constants mirrored by this client-only fixture. */
|
||||
const CHARS_PER_TOKEN = 4
|
||||
const BLOCK_OVERHEAD = 4
|
||||
const ROLE_OVERHEAD = 4
|
||||
|
||||
/** Price fixture content with token-meter's fixed-density heuristic. */
|
||||
function estimateFixtureContent(blocks: readonly ContentBlock[]): number {
|
||||
const densityPrice = (value: string): number => Math.ceil(value.length / CHARS_PER_TOKEN)
|
||||
return blocks.reduce((tokens, block) => {
|
||||
if (block.type === 'text' || block.type === 'reasoning') {
|
||||
return tokens + densityPrice(block.text) + BLOCK_OVERHEAD
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
return tokens + densityPrice(block.name) + densityPrice(block.arguments) + BLOCK_OVERHEAD
|
||||
}
|
||||
// ContentBlockMap is merge-extensible: this client graph sees only the
|
||||
// base four members, but fixture turns do carry extended blocks at
|
||||
// runtime, so the structural JSON fallback below is live code.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the type collapses without the out-of-graph merges (see above).
|
||||
if (block.type === 'tool-result') {
|
||||
return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD
|
||||
}
|
||||
return tokens + densityPrice(JSON.stringify(block)) + BLOCK_OVERHEAD
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/** Fixture parallel of token-meter's heuristic context-composition projection. */
|
||||
function contextBreakdownOf(log: readonly SessionEvent[]): FixtureContextBreakdownProjection {
|
||||
const headerEvent = log.findLast(event => event.type === 'request/header')
|
||||
const header = headerEvent === undefined
|
||||
? undefined
|
||||
: headerEvent.data.header
|
||||
let messageTokens = 0
|
||||
for (const seq of foldSurface(log).nodes) {
|
||||
const event = log[seq]
|
||||
if (event === undefined) continue
|
||||
const message = deriveEventMessage(event)
|
||||
if (message !== null) messageTokens += estimateFixtureContent(message.content) + ROLE_OVERHEAD
|
||||
}
|
||||
return {
|
||||
systemTokens: header?.system === undefined
|
||||
? 0
|
||||
: Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD,
|
||||
toolsTokens: header?.tools === undefined || header.tools.length === 0
|
||||
? 0
|
||||
: Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD,
|
||||
messageTokens,
|
||||
}
|
||||
}
|
||||
|
||||
/** Latest log-only route context, or undefined before any request ran. */
|
||||
function lastRequestContext(
|
||||
log: readonly SessionEvent[],
|
||||
@@ -835,7 +894,11 @@ function lastRequestContext(
|
||||
/**
|
||||
* Fixture parallel of token-meter's request-pressure projection: the last
|
||||
* provider-reported prompt size paired with the last recorded capacity. The
|
||||
* two need not come from one request — see the token-meter README.
|
||||
* two need not come from one request — see the token-meter README. The host's
|
||||
* `projectedTokens` is deliberately absent: reproducing it would mean
|
||||
* reimplementing the estimator client-side, and every consumer falls back to
|
||||
* the bare sample, so a fixture-driven view simply lags a compaction the way
|
||||
* the projection did before that field existed.
|
||||
*/
|
||||
function contextPressureOf(
|
||||
log: readonly SessionEvent[],
|
||||
@@ -873,41 +936,53 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
||||
values['tokenUsage'] = tokenUsageOf(log)
|
||||
// Always present (token-meter composed): last request pressure and capacity.
|
||||
values['contextPressure'] = contextPressureOf(log)
|
||||
// Always present (token-meter composed): heuristic request composition.
|
||||
values['contextBreakdown'] = contextBreakdownOf(log)
|
||||
return values
|
||||
}
|
||||
|
||||
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
|
||||
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
|
||||
const type = (event as { type: string }).type
|
||||
const frames: Extract<MuxFrame, { type: 'session/projection' }>[] = []
|
||||
// One usage sample advances both token-meter units.
|
||||
if (usageSampleOf(event) !== undefined) {
|
||||
return [
|
||||
frames.push(
|
||||
{ type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq },
|
||||
{ type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq },
|
||||
]
|
||||
)
|
||||
}
|
||||
if (type === 'request/context') {
|
||||
return [{
|
||||
frames.push({
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
key: 'contextPressure',
|
||||
value: contextPressureOf(log),
|
||||
seq: event.seq,
|
||||
}]
|
||||
})
|
||||
}
|
||||
if (type === 'request/header'
|
||||
|| type === 'user/message'
|
||||
|| type === 'assistant/message'
|
||||
|| type === 'tool/result') {
|
||||
frames.push({
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
key: 'contextBreakdown',
|
||||
value: contextBreakdownOf(log),
|
||||
seq: event.seq,
|
||||
})
|
||||
}
|
||||
if (frames.length > 0) return frames
|
||||
if (type === 'session/title') {
|
||||
const values = projectionValuesOf(log)
|
||||
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
|
||||
if (!Object.hasOwn(values, 'title')) return []
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
|
||||
}
|
||||
// Goal fold: a round-zero goal-sourced user message advances the goal unit.
|
||||
if (type === 'user/message') {
|
||||
const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source
|
||||
if (source?.kind === 'goal' && source.round === 0) {
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
|
||||
}
|
||||
return []
|
||||
// The goal domain's own durable change advances its projection.
|
||||
if (type === 'goal/change') {
|
||||
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
|
||||
}
|
||||
// Standing-plan fold: writes replace the list; turn/start clears it (null).
|
||||
if (type === 'todo/write' || type === 'turn/start') {
|
||||
@@ -961,7 +1036,7 @@ function pageOf(
|
||||
const event = log[i]
|
||||
/* v8 ignore next -- dense-array guard: log seqs are array indexes, i stays within [0, end). */
|
||||
if (event === undefined) break
|
||||
if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'steering/message') messages++
|
||||
if (event.type === 'user/message' || event.type === 'assistant/message') messages++
|
||||
if (event.type === 'turn/start' && messages >= maxMessages) {
|
||||
start = i
|
||||
break
|
||||
@@ -990,11 +1065,11 @@ function searchBlockText(block: ContentBlock): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/** One current-surface user/assistant/steering document, if searchable. */
|
||||
/** One current-surface user/assistant document, if searchable. */
|
||||
function searchEventText(event: SessionEvent): string {
|
||||
const content = event.type === 'user/message'
|
||||
? event.data.content
|
||||
: event.type === 'assistant/message' || event.type === 'steering/message'
|
||||
: event.type === 'assistant/message'
|
||||
? event.data.message.content
|
||||
: undefined
|
||||
if (content === undefined) return ''
|
||||
@@ -1140,7 +1215,7 @@ interface FxGoalProjection {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One durable goal change riding a round-zero goal-sourced user message. */
|
||||
/** One durable goal change. */
|
||||
type FxGoalChange =
|
||||
| { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
|
||||
| {
|
||||
@@ -1161,14 +1236,10 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
const event = log[i] as unknown as {
|
||||
type: string
|
||||
data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } }
|
||||
data?: FxGoalChange
|
||||
} | undefined
|
||||
if (event === undefined || event.type !== 'user/message') continue
|
||||
const source = event.data?.source
|
||||
if (source?.kind !== 'goal' || source.round !== 0) continue
|
||||
const change = source.change
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (change === undefined || change.kind !== 'goal/change') continue
|
||||
if (event === undefined || event.type !== 'goal/change' || event.data === undefined) continue
|
||||
const change = event.data
|
||||
if (change.operation === 'clear') return null
|
||||
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
|
||||
}
|
||||
@@ -1179,6 +1250,16 @@ interface StreamConn<F> {
|
||||
push(envelope: RpcRequest<F>): void
|
||||
}
|
||||
|
||||
interface ReasoningChunkStormState {
|
||||
sessionId: string
|
||||
chunkCount: number
|
||||
chunksPerInterval: number
|
||||
intervalMs: number
|
||||
emitted: number
|
||||
marker: string
|
||||
emitting: boolean
|
||||
}
|
||||
|
||||
/** Deterministic fixture branches used by keyless Web assembly tests. */
|
||||
export interface FixtureOptions {
|
||||
/** Start with no real Workspace or Session. */
|
||||
@@ -1409,20 +1490,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
|
||||
}
|
||||
|
||||
/** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */
|
||||
/** Append one durable goal/change (host GoalService parallel). */
|
||||
const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => {
|
||||
const ref = change.operation === 'clear' ? change.cleared : change.goal
|
||||
const payload = change.operation === 'clear'
|
||||
? { cleared: change.cleared, clearedAt: change.clearedAt }
|
||||
: { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
|
||||
const log = logOf(id)
|
||||
append(id, {
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(
|
||||
text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
|
||||
{ kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
|
||||
),
|
||||
type: 'goal/change',
|
||||
data: change,
|
||||
})
|
||||
return backscanGoal(logOf(id)) as FxGoalProjection
|
||||
return backscanGoal(log) as FxGoalProjection
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
|
||||
@@ -1461,6 +1536,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const streamBreakers = new Set<() => void>()
|
||||
/** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
|
||||
const retryScenarios = new Map<SessionId, { turn: number; stepStarted: boolean }>()
|
||||
/** The single opt-in browser stress producer; normal fixture journeys never start it. */
|
||||
let activeReasoningChunkStorm: ReasoningChunkStormState | null = null
|
||||
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
|
||||
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
|
||||
@@ -1484,6 +1561,86 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
|
||||
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
|
||||
},
|
||||
/** Start an externally paced reasoning stream for the opt-in browser stress lane. */
|
||||
startReasoningChunkStorm(
|
||||
id: string,
|
||||
chunkCount: number,
|
||||
chunksPerInterval: number,
|
||||
intervalMs: number,
|
||||
): string {
|
||||
if (!Number.isSafeInteger(chunkCount) || chunkCount < 1) {
|
||||
throw new Error('fixture: reasoning chunk count must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(chunksPerInterval) || chunksPerInterval < 1) {
|
||||
throw new Error('fixture: reasoning chunks per interval must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) {
|
||||
throw new Error('fixture: reasoning interval must be a positive safe integer')
|
||||
}
|
||||
if (activeReasoningChunkStorm?.emitting === true) {
|
||||
throw new Error('fixture: reasoning chunk storm already running')
|
||||
}
|
||||
|
||||
const sessionId = sid(id)
|
||||
const log = logOf(sessionId)
|
||||
let turn = nextTurn.get(sessionId) ?? 0
|
||||
for (const event of log) {
|
||||
const candidate = (event as unknown as { data?: { turn?: unknown } }).data?.turn
|
||||
if (typeof candidate === 'number') turn = Math.max(turn, candidate + 1)
|
||||
}
|
||||
nextTurn.set(sessionId, turn + 1)
|
||||
const marker = `REASONING_STRESS_COMPLETE:${String(turn)}:${String(chunkCount)}`
|
||||
const state: ReasoningChunkStormState = {
|
||||
sessionId: id,
|
||||
chunkCount,
|
||||
chunksPerInterval,
|
||||
intervalMs,
|
||||
emitted: 0,
|
||||
marker,
|
||||
emitting: true,
|
||||
}
|
||||
activeReasoningChunkStorm = state
|
||||
|
||||
setRunning(sessionId, true)
|
||||
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(sessionId, {
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(text(`Reasoning chunk stress: ${String(chunkCount)} chunks.`)),
|
||||
})
|
||||
append(sessionId, { type: 'step/start', data: { turn, step: 0 } })
|
||||
append(sessionId, {
|
||||
type: 'assistant/chunk',
|
||||
data: { turn, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
|
||||
})
|
||||
|
||||
const startedAt = Date.now()
|
||||
const pump = (): void => {
|
||||
const elapsedIntervals = Math.floor((Date.now() - startedAt) / intervalMs) + 1
|
||||
const due = Math.max(state.emitted + chunksPerInterval, elapsedIntervals * chunksPerInterval)
|
||||
const end = Math.min(due, chunkCount)
|
||||
for (let index = state.emitted; index < end; index++) {
|
||||
const chunkText = index === chunkCount - 1
|
||||
? `\n${marker}`
|
||||
: index % 64 === 63 ? '推理\n' : '推理'
|
||||
append(sessionId, {
|
||||
type: 'assistant/chunk',
|
||||
data: { turn, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: chunkText } },
|
||||
})
|
||||
}
|
||||
state.emitted = end
|
||||
if (end < chunkCount) {
|
||||
setTimeout(pump, intervalMs)
|
||||
} else {
|
||||
state.emitting = false
|
||||
}
|
||||
}
|
||||
setTimeout(pump, 0)
|
||||
return marker
|
||||
},
|
||||
/** Return a copy so browser probes cannot mutate the active producer. */
|
||||
reasoningChunkStormState(): ReasoningChunkStormState | null {
|
||||
return activeReasoningChunkStorm === null ? null : { ...activeReasoningChunkStorm }
|
||||
},
|
||||
/** Open one failed model step whose partial remains visible until llm/retry arrives. */
|
||||
beginModelRetry(id: string): void {
|
||||
const sessionId = sid(id)
|
||||
@@ -1491,23 +1648,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
nextTurn.set(sessionId, turn + 1)
|
||||
retryScenarios.set(sessionId, { turn, stepStarted: true })
|
||||
setRunning(sessionId, true)
|
||||
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(sessionId, { type: 'turn/start', data: { turn } })
|
||||
append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
|
||||
append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
|
||||
append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
|
||||
},
|
||||
/** Record one retry decision, then open the next retry turn. */
|
||||
/** Record one retry decision; the next attempt remains in the same step. */
|
||||
scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
|
||||
const sessionId = sid(id)
|
||||
const scenario = retryScenarios.get(sessionId)
|
||||
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
|
||||
if (!scenario.stepStarted) {
|
||||
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } })
|
||||
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
|
||||
scenario.stepStarted = true
|
||||
}
|
||||
const failure = { code: 'TRANSPORT', message: '连接被重置' }
|
||||
@@ -1519,14 +1673,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
retry, maxRetries: 2, delayMs, failure,
|
||||
},
|
||||
})
|
||||
append(sessionId, {
|
||||
type: 'turn/end',
|
||||
data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
|
||||
})
|
||||
const next = nextTurn.get(sessionId) ?? scenario.turn + 1
|
||||
nextTurn.set(sessionId, next + 1)
|
||||
append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
|
||||
scenario.turn = next
|
||||
scenario.stepStarted = false
|
||||
},
|
||||
/** Record one retry decision, then cancel its source turn before the retry starts. */
|
||||
@@ -1543,17 +1689,23 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
retry: 1, maxRetries: 2, delayMs, failure,
|
||||
},
|
||||
})
|
||||
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
|
||||
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
|
||||
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
} })
|
||||
retryScenarios.delete(sessionId)
|
||||
setRunning(sessionId, false)
|
||||
},
|
||||
/** Finish the timing-hook retry with a finalized response in the open retry turn. */
|
||||
/** Finish the timing-hook retry with a finalized response in the open step. */
|
||||
completeModelRetry(id: string): void {
|
||||
const sessionId = sid(id)
|
||||
const scenario = retryScenarios.get(sessionId)
|
||||
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
|
||||
retryScenarios.delete(sessionId)
|
||||
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
|
||||
append(sessionId, { type: 'assistant/chunk', data: {
|
||||
turn: scenario.turn,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'text' },
|
||||
} })
|
||||
append(sessionId, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: 'append',
|
||||
@@ -1852,17 +2004,15 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
summary.blank = false
|
||||
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (mode === 'steer' && replays.has(id)) {
|
||||
// Steering: insert a steering message into the current turn; the replay continues.
|
||||
/* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
|
||||
const turn = (nextTurn.get(id) ?? 1) - 1
|
||||
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(content) } })
|
||||
// Steering: the durable user/message lands inside the current turn; the replay continues.
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
return ok(request, { accepted: true as const })
|
||||
}
|
||||
const turn = nextTurn.get(id) ?? 0
|
||||
nextTurn.set(id, turn + 1)
|
||||
setRunning(id, true)
|
||||
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
// Boundary flush parallel (the host's agent/step seam): an outstanding
|
||||
append(id, { type: 'turn/start', data: { turn } })
|
||||
// Boundary flush parallel (the host's step/start observer): an outstanding
|
||||
// /plan selection commits as plan/mode inside the opened turn.
|
||||
const plan = foldPlan(logOf(id))
|
||||
if (plan.wanted !== null && plan.wanted !== plan.active) {
|
||||
@@ -1910,6 +2060,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
},
|
||||
subagents: {
|
||||
list: request => ok(request, { entries: [], parentAvailable: true }),
|
||||
history: (request) => {
|
||||
const log = logs.get(request.payload.childSessionId) ?? []
|
||||
return Promise.resolve(ok(
|
||||
request,
|
||||
pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50),
|
||||
))
|
||||
},
|
||||
prompt: request => Promise.resolve(ok(request, {
|
||||
messageId: `fixture-message-${request.payload.childSessionId}` as never,
|
||||
})),
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
// Deterministic native pick: the keyless lanes drive the full
|
||||
@@ -2285,6 +2448,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// editor; real schema-driven forms ride the HTTP transport.
|
||||
describe: request => ok(request, {
|
||||
writable: true,
|
||||
hasDocument: true,
|
||||
namespaces: [{
|
||||
ns: 'llm-deepseek',
|
||||
schema: {},
|
||||
@@ -2294,6 +2458,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
revision: 0,
|
||||
}],
|
||||
}),
|
||||
// Native opens are deterministic no-op successes in this fixture, as is host.openPath.
|
||||
openDocument: request => ok(request, { opened: true as const }),
|
||||
update: request => err(request, {
|
||||
code: 'settings-rejected',
|
||||
message: 'fixture: the minimal readiness settings descriptor is read-only',
|
||||
@@ -2336,6 +2502,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
],
|
||||
}),
|
||||
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
|
||||
// The fixture endpoint is imaginary, so the interrogation answers the
|
||||
// catalog it already serves — enough for a surface to exercise adopting
|
||||
// candidates without a reachable provider.
|
||||
discoverModels: request => ok(request, {
|
||||
models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))),
|
||||
}),
|
||||
},
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
// Same routing discipline as the host: rpcId first, then the payload's
|
||||
@@ -2420,6 +2592,9 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
case 'subagent.history': return this.api.subagents.history(request)
|
||||
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
@@ -2441,6 +2616,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'goal.complete': return this.api.goals.complete(request)
|
||||
case 'goal.clear': return this.api.goals.clear(request)
|
||||
case 'settings.describe': return this.api.settings.describe(request)
|
||||
case 'settings.openDocument': return this.api.settings.openDocument(request, signal)
|
||||
case 'settings.update': return this.api.settings.update(request)
|
||||
case 'settings.replace': return this.api.settings.replace(request)
|
||||
case 'settings.mutate': return this.api.settings.mutate(request)
|
||||
@@ -2449,6 +2625,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'credentials.unset': return this.api.credentials.unset(request)
|
||||
case 'llm.providers': return this.api.llm.providers(request)
|
||||
case 'llm.models': return this.api.llm.models(request)
|
||||
case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
import { isLoopbackHostname } from '../loopback-hostname.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
@@ -17,13 +18,14 @@ export type {
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
} from './api.ts'
|
||||
export {
|
||||
RpcId,
|
||||
@@ -47,6 +49,8 @@ export const inject: string[] = []
|
||||
export interface ConnectionHandle {
|
||||
/** Shared api client (fixture or real, decided at boot from the page URL). */
|
||||
readonly api: IApiClient
|
||||
/** Whether the current page authority is loopback; non-browser contexts default to true. */
|
||||
readonly isLoopback: boolean
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
@@ -63,11 +67,13 @@ export interface ConnectionHandle {
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture')
|
||||
const pageLocation = typeof location === 'undefined' ? undefined : location
|
||||
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
|
||||
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
@@ -1,12 +1,91 @@
|
||||
// WebApiClient: the browser platform subclass — transport = global fetch over same-origin
|
||||
// /api/* (base resolution handled by AbstractApiClient). Envelope observation comes from the
|
||||
// base batching aspect; subscribers attach via subscribeEnvelopes (see boot).
|
||||
/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */
|
||||
|
||||
import type { ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest } from './api.ts'
|
||||
import { AbstractApiClient } from './api.ts'
|
||||
import { hostFrameSchema, muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema'
|
||||
import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema'
|
||||
import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../api-path.ts'
|
||||
|
||||
/** Browser platform subclass: transport = global fetch over same-origin /api/*. */
|
||||
type SocketItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' }
|
||||
type Parser<F> = { parse(value: unknown): F }
|
||||
|
||||
/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */
|
||||
export class WebApiClient extends AbstractApiClient {
|
||||
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
protected override openMux(
|
||||
_payload: Parameters<ApiProxy['events']['mux']>[0]['payload'],
|
||||
signal: AbortSignal,
|
||||
onOpen?: () => void,
|
||||
): AsyncIterable<RpcRequest<MuxFrame>> {
|
||||
return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen)
|
||||
}
|
||||
|
||||
protected override openHost(
|
||||
_payload: Parameters<ApiProxy['events']['host']>[0]['payload'],
|
||||
signal: AbortSignal,
|
||||
onOpen?: () => void,
|
||||
): AsyncIterable<RpcRequest<HostFrame>> {
|
||||
return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen)
|
||||
}
|
||||
|
||||
private async *readWebSocket<F extends MuxFrame | HostFrame>(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
frameSchema: Parser<F>,
|
||||
onOpen?: () => void,
|
||||
): AsyncGenerator<RpcRequest<F>> {
|
||||
const url = new URL(path, this.resolveBase())
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const socket = new WebSocket(url)
|
||||
const inbox: SocketItem<F>[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const enqueue = (item: SocketItem<F>): void => {
|
||||
inbox.push(item)
|
||||
wake?.()
|
||||
wake = undefined
|
||||
}
|
||||
const handleOpen = (): void => { onOpen?.() }
|
||||
const handleMessage = (event: MessageEvent): void => {
|
||||
let full: ServerRequest
|
||||
let frame: F
|
||||
try {
|
||||
if (typeof event.data !== 'string') throw new Error('binary WebSocket frame')
|
||||
full = serverRequestSchema.parse(JSON.parse(event.data))
|
||||
frame = frameSchema.parse(full.payload)
|
||||
} catch (error) {
|
||||
console.error(`[client-connection] dropping malformed WebSocket frame on ${path}:`, error)
|
||||
return
|
||||
}
|
||||
this.onEnvelope(full)
|
||||
enqueue({ kind: 'frame', envelope: { rpcId: full.rpcId, payload: frame } })
|
||||
}
|
||||
const handleClose = (): void => { enqueue({ kind: 'end' }) }
|
||||
const handleAbort = (): void => {
|
||||
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close()
|
||||
}
|
||||
socket.addEventListener('open', handleOpen)
|
||||
socket.addEventListener('message', handleMessage)
|
||||
socket.addEventListener('close', handleClose, { once: true })
|
||||
signal.addEventListener('abort', handleAbort, { once: true })
|
||||
if (signal.aborted) handleAbort()
|
||||
try {
|
||||
while (true) {
|
||||
while (inbox.length > 0) {
|
||||
const item = inbox.shift() as SocketItem<F>
|
||||
if (item.kind === 'end') return
|
||||
yield item.envelope
|
||||
}
|
||||
await new Promise<void>((resolve) => { wake = resolve })
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', handleAbort)
|
||||
socket.removeEventListener('open', handleOpen)
|
||||
socket.removeEventListener('message', handleMessage)
|
||||
socket.removeEventListener('close', handleClose)
|
||||
handleAbort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
@@ -43,21 +44,28 @@ export const Config: z<ConnectionConfig> = z.object({
|
||||
* reconnaissance no anonymous caller should have. `trustedHosts` is a
|
||||
* DNS-rebinding fence, explicitly not authentication, so the whole
|
||||
* configuration plane stays loopback-same-origin until a real authentication
|
||||
* layer exists. The model catalog (`llm.providers`, `llm.models`) is
|
||||
* deliberately NOT here: it carries provider ids, display names, and model
|
||||
* lists — no endpoints, keys, or key state — and a LAN client's model picker
|
||||
* legitimately needs it.
|
||||
* layer exists. `llm.discoverModels` belongs to that plane on both counts: it
|
||||
* carries a draft credential, and it makes the HOST issue a GET to a URL the
|
||||
* caller chose and reports back the status or the parsed body — an anonymous
|
||||
* LAN caller would have a probe for whatever the host can reach and the
|
||||
* browser cannot.
|
||||
*
|
||||
* The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here:
|
||||
* it carries provider ids, display names, and model lists — no endpoints,
|
||||
* keys, or key state — and a LAN client's model picker legitimately needs it.
|
||||
*/
|
||||
const PRIVILEGED_METHODS = new Set([
|
||||
'host.pickDirectory',
|
||||
'host.openPath',
|
||||
'settings.describe',
|
||||
'settings.openDocument',
|
||||
'settings.update',
|
||||
'settings.replace',
|
||||
'settings.mutate',
|
||||
'credentials.describe',
|
||||
'credentials.set',
|
||||
'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -76,6 +84,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const downlinks = new WebSocketDownlinks(ctx.apiProxy)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
@@ -92,8 +101,31 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
|
||||
res.end('upgrade required')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
}
|
||||
|
||||
18
packages/client/connection/src/loopback-hostname.ts
Normal file
18
packages/client/connection/src/loopback-hostname.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Browser-safe, zero-dependency loopback classification shared by the `/api`
|
||||
* Host fence and the package's `ctx.connection` state. The predicate stays
|
||||
* package-internal; client plugins consume the derived state through Cordis.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether a normalized URL hostname names the local loopback authority.
|
||||
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
|
||||
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
|
||||
*/
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
153
packages/client/connection/src/websocket-downlink.ts
Normal file
153
packages/client/connection/src/websocket-downlink.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/** Host-side WebSocket carrier for the two server-to-browser event streams. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import WebSocket, { WebSocketServer } from 'ws'
|
||||
import type {
|
||||
ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
type Frame = MuxFrame | HostFrame
|
||||
|
||||
function serverRequest(frame: RpcRequest<Frame>): ServerRequest {
|
||||
return {
|
||||
type: 'server-request',
|
||||
rpcId: frame.rpcId,
|
||||
method: frame.payload.type,
|
||||
payload: frame.payload,
|
||||
}
|
||||
}
|
||||
|
||||
function send(socket: WebSocket, frame: RpcRequest<Frame>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (socket.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('websocket downlink closed before frame delivery'))
|
||||
return
|
||||
}
|
||||
socket.send(JSON.stringify(serverRequest(frame)), (error) => {
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function failureFrame(error: unknown): RpcRequest<Frame> {
|
||||
return {
|
||||
rpcId: RpcId(randomUUID()),
|
||||
payload: {
|
||||
type: 'stream/error',
|
||||
error: { code: 'internal', message: String(error), details: {} },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns WebSocket negotiation and frame pumping for the connection plugin's
|
||||
* two downlinks. Client messages are a protocol violation: upstream traffic
|
||||
* remains on HTTP.
|
||||
*/
|
||||
export class WebSocketDownlinks {
|
||||
private readonly server = new WebSocketServer({ noServer: true })
|
||||
private readonly pumps = new Set<Promise<void>>()
|
||||
|
||||
/** @param api - host API supplying the typed event streams. */
|
||||
constructor(private readonly api: ApiProxy) {}
|
||||
|
||||
/**
|
||||
* Upgrade one socket and pump the mux stream until either side closes.
|
||||
* @param req - HTTP upgrade request.
|
||||
* @param socket - Raw socket transferred by the HTTP server.
|
||||
* @param head - Bytes already read after the upgrade headers.
|
||||
*/
|
||||
handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
this.upgrade(req, socket, head, signal => this.api.events.mux({
|
||||
rpcId: RpcId(randomUUID()),
|
||||
payload: {},
|
||||
}, signal))
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade one socket and pump the host stream until either side closes.
|
||||
* @param req - HTTP upgrade request.
|
||||
* @param socket - Raw socket transferred by the HTTP server.
|
||||
* @param head - Bytes already read after the upgrade headers.
|
||||
*/
|
||||
handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
this.upgrade(req, socket, head, signal => this.api.events.host({
|
||||
rpcId: RpcId(randomUUID()),
|
||||
payload: {},
|
||||
}, signal))
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate owned sockets and await the no-server acceptor plus frame pumps.
|
||||
* @returns A promise resolving after every socket and source iterator stops.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
for (const socket of this.server.clients) socket.terminate()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
await Promise.all(this.pumps)
|
||||
}
|
||||
|
||||
private upgrade<F extends Frame>(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
open: (signal: AbortSignal) => AsyncIterable<RpcRequest<F>>,
|
||||
): void {
|
||||
this.server.handleUpgrade(req, socket, head, (websocket) => {
|
||||
const abort = new AbortController()
|
||||
websocket.once('close', () => { abort.abort() })
|
||||
websocket.once('error', () => { abort.abort() })
|
||||
websocket.once('message', () => {
|
||||
websocket.close(1008, 'downlink only')
|
||||
})
|
||||
const pump = this.pump(websocket, open(abort.signal), abort)
|
||||
this.pumps.add(pump)
|
||||
void pump.then(() => { this.pumps.delete(pump) })
|
||||
})
|
||||
}
|
||||
|
||||
private async pump<F extends Frame>(
|
||||
socket: WebSocket,
|
||||
frames: AsyncIterable<RpcRequest<F>>,
|
||||
abort: AbortController,
|
||||
): Promise<void> {
|
||||
try {
|
||||
for await (const frame of frames) await send(socket, frame)
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
try {
|
||||
await send(socket, failureFrame(error))
|
||||
} catch {
|
||||
// Socket loss won the race; no downstream remains to receive the failure frame.
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
abort.abort()
|
||||
if (socket.readyState === WebSocket.OPEN) socket.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an untrusted upgrade before protocol negotiation.
|
||||
* @param socket - Raw HTTP socket that remains owned by the caller.
|
||||
*/
|
||||
export function rejectWebSocketUpgrade(socket: Duplex): void {
|
||||
socket.end([
|
||||
'HTTP/1.1 403 Forbidden',
|
||||
'Connection: close',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Length: 9',
|
||||
'',
|
||||
'forbidden',
|
||||
].join('\r\n'))
|
||||
}
|
||||
@@ -3,15 +3,55 @@
|
||||
* selection off the page URL, and the single-consumer stream-loop ownership.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apply, type ConnectionHandle } from '../src/client/index.ts'
|
||||
import type { RpcMessage } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import { FixtureApiClient } from '../src/client/fixture.ts'
|
||||
import { WebApiClient } from '../src/client/web-api-client.ts'
|
||||
|
||||
type Win = { location?: { search: string } }
|
||||
type Win = { location?: { hostname: string; search: string; origin?: string } }
|
||||
type WebSocketGlobal = { WebSocket?: typeof WebSocket }
|
||||
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
const sockets: FakeWebSocket[] = []
|
||||
|
||||
class FakeWebSocket extends EventTarget {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSING = 2
|
||||
static readonly CLOSED = 3
|
||||
|
||||
readonly url: string
|
||||
readyState = FakeWebSocket.CONNECTING
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super()
|
||||
this.url = String(url)
|
||||
sockets.push(this)
|
||||
queueMicrotask(() => {
|
||||
if (this.readyState !== FakeWebSocket.CONNECTING) return
|
||||
this.readyState = FakeWebSocket.OPEN
|
||||
this.dispatchEvent(new Event('open'))
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === FakeWebSocket.CLOSED) return
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.dispatchEvent(new Event('close'))
|
||||
}
|
||||
|
||||
receive(data: unknown): void {
|
||||
this.dispatchEvent(new MessageEvent('message', { data }))
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Win).location
|
||||
sockets.length = 0
|
||||
if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
|
||||
else globalThis.WebSocket = originalWebSocket
|
||||
})
|
||||
|
||||
async function mount(): Promise<ConnectionHandle> {
|
||||
@@ -24,20 +64,28 @@ async function mount(): Promise<ConnectionHandle> {
|
||||
|
||||
describe('connection client apply', () => {
|
||||
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
|
||||
;(globalThis as Win).location = { search: '' }
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
const handle = await mount()
|
||||
expect(handle.api).toBeInstanceOf(WebApiClient)
|
||||
expect(handle.isLoopback).toBe(true)
|
||||
})
|
||||
|
||||
it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
|
||||
;(globalThis as Win).location = { search: '?fixture' }
|
||||
;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' }
|
||||
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
|
||||
delete (globalThis as Win).location
|
||||
expect((await mount()).api).toBeInstanceOf(WebApiClient)
|
||||
const handle = await mount()
|
||||
expect(handle.api).toBeInstanceOf(WebApiClient)
|
||||
expect(handle.isLoopback).toBe(true)
|
||||
})
|
||||
|
||||
it('reports non-loopback page authority through the connection handle', async () => {
|
||||
;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' }
|
||||
expect((await mount()).isLoopback).toBe(false)
|
||||
})
|
||||
|
||||
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
|
||||
;(globalThis as Win).location = { search: '?fixture' }
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
// config omitted: the `config ?? {}` default arm is part of the surface.
|
||||
const loop = handle.start({})
|
||||
@@ -45,8 +93,8 @@ describe('connection client apply', () => {
|
||||
loop.stop() // teardown must not throw; the fixture streams abort quietly
|
||||
})
|
||||
|
||||
it('WebApiClient carries requests over globalThis.fetch', async () => {
|
||||
;(globalThis as Win).location = { search: '' }
|
||||
it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: string[] = []
|
||||
@@ -57,9 +105,102 @@ describe('connection client apply', () => {
|
||||
try {
|
||||
// Schema rejection is fine — the transport hop is the assertion.
|
||||
await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
|
||||
await handle.api.respond({
|
||||
type: 'client-response',
|
||||
rpcId: RpcId('response-over-http'),
|
||||
result: { ok: true, value: {} },
|
||||
}).catch(() => undefined)
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
expect(seen.some(u => u.includes('/api/'))).toBe(true)
|
||||
expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true)
|
||||
expect(seen.some(u => u.includes('/api/respond'))).toBe(true)
|
||||
})
|
||||
|
||||
it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'localhost', search: '', origin: 'http://localhost:3080',
|
||||
}
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
const fetch = vi.spyOn(globalThis, 'fetch')
|
||||
const client = (await mount()).api as WebApiClient
|
||||
const envelopes: RpcMessage[][] = []
|
||||
client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) })
|
||||
const opened: string[] = []
|
||||
const muxAbort = new AbortController()
|
||||
const hostAbort = new AbortController()
|
||||
const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]()
|
||||
const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]()
|
||||
const muxFrame = mux.next()
|
||||
const hostFrame = host.next()
|
||||
await vi.waitFor(() => { expect(sockets).toHaveLength(2) })
|
||||
expect(sockets.map(socket => socket.url)).toEqual([
|
||||
'ws://localhost:3080/api/events.mux',
|
||||
'ws://localhost:3080/api/events.host',
|
||||
])
|
||||
await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) })
|
||||
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
sockets[0]!.receive(new Uint8Array([1, 2, 3]))
|
||||
sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} }))
|
||||
sockets[0]!.receive(JSON.stringify({
|
||||
type: 'server-request',
|
||||
rpcId: 'mux-browser',
|
||||
method: 'session/subscribed',
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 },
|
||||
}))
|
||||
sockets[1]!.receive(JSON.stringify({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-browser',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
}))
|
||||
expect(await muxFrame).toMatchObject({
|
||||
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
|
||||
})
|
||||
expect(await hostFrame).toMatchObject({
|
||||
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
|
||||
})
|
||||
expect(errors).toHaveBeenCalledTimes(2)
|
||||
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
|
||||
const muxEnd = mux.next()
|
||||
const hostEnd = host.next()
|
||||
muxAbort.abort()
|
||||
hostAbort.abort()
|
||||
await expect(muxEnd).resolves.toMatchObject({ done: true })
|
||||
await expect(hostEnd).resolves.toMatchObject({ done: true })
|
||||
expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true)
|
||||
errors.mockRestore()
|
||||
fetch.mockRestore()
|
||||
})
|
||||
|
||||
it('maps an HTTPS page origin to a secure WebSocket URL', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'harness.example', search: '', origin: 'https://harness.example',
|
||||
}
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
const client = (await mount()).api
|
||||
const abort = new AbortController()
|
||||
const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
|
||||
const pending = iterator.next()
|
||||
await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') })
|
||||
abort.abort()
|
||||
await expect(pending).resolves.toMatchObject({ done: true })
|
||||
})
|
||||
|
||||
it('closes a WebSocket immediately when its signal was already aborted', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'localhost', search: '', origin: 'http://localhost:3080',
|
||||
}
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
const client = (await mount()).api
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toMatchObject({ done: true })
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,6 +113,20 @@ export class FakeApiClient implements IApiClient {
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
|
||||
entries: [],
|
||||
parentAvailable: true,
|
||||
}))),
|
||||
history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
}))),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
|
||||
messageId: 'fake-message' as never,
|
||||
}))),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
@@ -167,7 +181,8 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
|
||||
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
@@ -182,6 +197,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
|
||||
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
|
||||
@@ -19,6 +19,16 @@ interface TimingHooks {
|
||||
failNextHistory(): void
|
||||
appendUser(id: string, msg: string): void
|
||||
appendTitle(id: string, title: string): void
|
||||
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
|
||||
reasoningChunkStormState(): {
|
||||
sessionId: string
|
||||
chunkCount: number
|
||||
chunksPerInterval: number
|
||||
intervalMs: number
|
||||
emitted: number
|
||||
marker: string
|
||||
emitting: boolean
|
||||
} | null
|
||||
beginModelRetry(id: string): void
|
||||
scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
|
||||
cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
|
||||
@@ -149,6 +159,11 @@ describe('createFixtureApi', () => {
|
||||
},
|
||||
// No request ran, so neither pressure nor capacity is known yet.
|
||||
contextPressure: {},
|
||||
contextBreakdown: {
|
||||
systemTokens: 0,
|
||||
toolsTokens: 0,
|
||||
messageTokens: 0,
|
||||
},
|
||||
} },
|
||||
})
|
||||
})
|
||||
@@ -294,6 +309,10 @@ describe('createFixtureApi', () => {
|
||||
frame.type === 'session/projection'
|
||||
&& frame.key === 'contextPressure'
|
||||
&& (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true)
|
||||
expect(frames.some(frame =>
|
||||
frame.type === 'session/projection'
|
||||
&& frame.key === 'contextBreakdown'
|
||||
&& (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true)
|
||||
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
|
||||
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
|
||||
// Idle cancel: no replay in flight, must not explode; running flips false.
|
||||
@@ -301,7 +320,7 @@ describe('createFixtureApi', () => {
|
||||
expect(idleCancel.result).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('steer during a replay inserts a steering message and the replay continues to completion', async () => {
|
||||
it('steer during a replay lands a user/message inside the current turn and the replay continues', async () => {
|
||||
const api = createFixtureApi()
|
||||
const created = await api.sessions.create(req({}))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
@@ -314,7 +333,7 @@ describe('createFixtureApi', () => {
|
||||
await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] }))
|
||||
const frames = await framesPromise
|
||||
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
||||
expect(types).toContain('steering/message')
|
||||
expect(JSON.stringify(frames)).toContain('插话')
|
||||
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
|
||||
})
|
||||
|
||||
@@ -325,7 +344,7 @@ describe('createFixtureApi', () => {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 10) abort.abort()
|
||||
if (envelopes.length >= 11) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
@@ -341,10 +360,15 @@ describe('createFixtureApi', () => {
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' })
|
||||
expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' })
|
||||
expect(first[8]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[8]?.rpcId).toBe(first[8]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[9]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[9]?.rpcId).toBe(first[9]?.rpcId)
|
||||
expect(first[8]?.payload).toMatchObject({
|
||||
type: 'session/projection', sessionId: 'fx-alpha', key: 'contextBreakdown',
|
||||
value: { systemTokens: 0, toolsTokens: 0 },
|
||||
})
|
||||
expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
|
||||
expect(first[9]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[9]?.rpcId).toBe(first[9]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[10]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[10]?.rpcId).toBe(first[10]?.rpcId)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
@@ -362,7 +386,7 @@ describe('createFixtureApi', () => {
|
||||
}))
|
||||
const frames = await framesPromise
|
||||
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
|
||||
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert
|
||||
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not an in-turn insert
|
||||
})
|
||||
|
||||
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
|
||||
@@ -873,6 +897,50 @@ describe('createFixtureApi', () => {
|
||||
expect(abort.signal.aborted).toBe(false)
|
||||
expect(habort.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('paces the opt-in reasoning stress hook from an external interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const api = createFixtureApi()
|
||||
const hooks = timing()
|
||||
expect(hooks.reasoningChunkStormState()).toBeNull()
|
||||
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 0, 1, 16)).toThrow(/chunk count/)
|
||||
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 0, 16)).toThrow(/chunks per interval/)
|
||||
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/)
|
||||
const abort = new AbortController()
|
||||
try {
|
||||
const streamed = collect(api.events.mux(req({}), abort.signal), abort, frames => frames.some(frame => (
|
||||
frame.type === 'session/event'
|
||||
&& frame.event.type === 'assistant/chunk'
|
||||
&& frame.event.data.chunk.type === 'reasoning-delta'
|
||||
&& frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE')
|
||||
)))
|
||||
const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16)
|
||||
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 16)).toThrow(/already running/)
|
||||
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 0, emitting: true, marker })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 2, emitting: true })
|
||||
await vi.advanceTimersByTimeAsync(16)
|
||||
expect(hooks.reasoningChunkStormState()).toEqual({
|
||||
sessionId: 'fx-alpha', chunkCount: 3, chunksPerInterval: 2, intervalMs: 16,
|
||||
emitted: 3, marker, emitting: false,
|
||||
})
|
||||
|
||||
const frames = await streamed
|
||||
const deltas = frames.flatMap(frame => (
|
||||
frame.type === 'session/event'
|
||||
&& frame.event.type === 'assistant/chunk'
|
||||
&& frame.event.data.chunk.type === 'reasoning-delta'
|
||||
? [frame.event.data.chunk.text]
|
||||
: []
|
||||
))
|
||||
expect(deltas).toEqual(['推理', '推理', `\n${marker}`])
|
||||
} finally {
|
||||
abort.abort()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
@@ -954,6 +1022,21 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
// complete → complete is an invalid transition.
|
||||
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
|
||||
expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
|
||||
|
||||
const goalHistory = await client.sessions.history({ sessionId: id })
|
||||
if (!goalHistory.result.ok) throw new Error('goal history failed')
|
||||
const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as {
|
||||
type: string
|
||||
data: {
|
||||
operation?: string
|
||||
source?: { kind?: string; round?: number }
|
||||
}
|
||||
})
|
||||
const goalChanges = goalEvents.filter(event => event.type === 'goal/change')
|
||||
expect(goalChanges.map(event => event.data.operation))
|
||||
.toEqual(['create', 'edit', 'pause', 'resume', 'complete', 'clear'])
|
||||
expect(goalEvents.some(event => event.type === 'user/message'
|
||||
&& event.data.source?.kind === 'goal' && event.data.source.round === 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
|
||||
|
||||
18
packages/client/connection/tests/loopback-hostname.spec.ts
Normal file
18
packages/client/connection/tests/loopback-hostname.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Shared loopback-hostname semantics for the Host fence and browser UI. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isLoopbackHostname } from '../src/loopback-hostname.ts'
|
||||
|
||||
describe('isLoopbackHostname', () => {
|
||||
it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => {
|
||||
for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) {
|
||||
expect(isLoopbackHostname(hostname)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses malformed and non-loopback hostnames', () => {
|
||||
for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) {
|
||||
expect(isLoopbackHostname(hostname)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,29 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { EventEmitter, once } from 'node:events'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { Readable } from 'node:stream'
|
||||
import { PassThrough, Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake: the plugin only touches register(). */
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
|
||||
/** Structural httpServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
routes: WebRoute[],
|
||||
upgrades: WebUpgradeRoute[],
|
||||
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
registerUpgrade(route) {
|
||||
upgrades.push(route)
|
||||
return () => { upgrades.splice(upgrades.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
@@ -45,33 +52,67 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b
|
||||
return { response, state }
|
||||
}
|
||||
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{
|
||||
routes: WebRoute[]
|
||||
upgrades: WebUpgradeRoute[]
|
||||
dispose: () => Promise<void>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
const upgrades: WebUpgradeRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
|
||||
await fiber.await()
|
||||
return { routes, dispose: () => fiber.dispose() }
|
||||
return { routes, upgrades, dispose: () => fiber.dispose() }
|
||||
}
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
|
||||
const routes: WebRoute[] = []
|
||||
const upgrades: WebUpgradeRoute[] = []
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
|
||||
await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
|
||||
expect(routes).toHaveLength(0)
|
||||
expect(upgrades).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => {
|
||||
const { routes, upgrades, dispose } = await mounted()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH])
|
||||
await dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
expect(upgrades).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('requires WebSocket upgrade for network GETs to either event path', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) {
|
||||
const { response, state } = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response)
|
||||
expect(state.status).toBe(426)
|
||||
expect(state.body).toBe('upgrade required')
|
||||
}
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => {
|
||||
const { upgrades, dispose } = await mounted()
|
||||
const socket = new PassThrough()
|
||||
const chunks: Buffer[] = []
|
||||
socket.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
const ended = once(socket, 'end')
|
||||
await upgrades[0]!.handler(fakeRequest({
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
}, MUX_EVENTS_PATH), socket, Buffer.alloc(0))
|
||||
await ended
|
||||
expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
|
||||
@@ -88,13 +129,15 @@ describe('connection node half', () => {
|
||||
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
// The privileged set: native dialogs plus the whole settings/credential
|
||||
// configuration plane, reads included. The same declared authority reaches
|
||||
// configuration plane, reads included, plus the one method that makes the
|
||||
// host fetch a caller-chosen URL. The same declared authority reaches
|
||||
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -177,9 +220,12 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Reads are as privileged as writes: describe returns the exposed
|
||||
// configuration, and credentials.describe probes arbitrary env-var names.
|
||||
for (const method of [
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
// Carries a draft credential and turns the host into a fetcher for a
|
||||
// URL the caller picked: an anonymous LAN caller must not reach it.
|
||||
'llm.discoverModels',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
|
||||
308
packages/client/connection/tests/websocket-downlink.spec.ts
Normal file
308
packages/client/connection/tests/websocket-downlink.spec.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { once } from 'node:events'
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import type {
|
||||
ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts'
|
||||
import { WebSocketDownlinks } from '../src/websocket-downlink.ts'
|
||||
|
||||
type MuxSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<MuxFrame>>
|
||||
type HostSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<HostFrame>>
|
||||
|
||||
const running: (() => Promise<void>)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(running.splice(0).map(close => close()))
|
||||
})
|
||||
|
||||
function untilAbort(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function * idle<F>(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
|
||||
await untilAbort(signal)
|
||||
}
|
||||
|
||||
function api(mux: MuxSource, host: HostSource): ApiProxy {
|
||||
return {
|
||||
events: {
|
||||
mux: (_request, signal) => mux(signal),
|
||||
host: (_request, signal) => host(signal),
|
||||
},
|
||||
} as ApiProxy
|
||||
}
|
||||
|
||||
async function serve(downlinks: WebSocketDownlinks): Promise<{
|
||||
origin: string
|
||||
close: () => Promise<void>
|
||||
}> {
|
||||
const server = createServer()
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname
|
||||
if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head)
|
||||
else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head)
|
||||
else socket.destroy()
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = (server.address() as AddressInfo).port
|
||||
return {
|
||||
origin: `ws://127.0.0.1:${String(port)}`,
|
||||
close: async () => {
|
||||
await downlinks.close()
|
||||
await new Promise<void>(resolve => server.close(() => { resolve() }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function read(socket: WebSocket): Promise<ServerRequest> {
|
||||
return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest)
|
||||
}
|
||||
|
||||
async function acceptedSocket(downlinks: WebSocketDownlinks): Promise<WebSocket> {
|
||||
const server = (downlinks as unknown as { server: { clients: Set<WebSocket> } }).server
|
||||
let accepted: WebSocket | undefined
|
||||
await vi.waitFor(() => {
|
||||
accepted = server.clients.values().next().value
|
||||
expect(accepted).toBeDefined()
|
||||
})
|
||||
return accepted as WebSocket
|
||||
}
|
||||
|
||||
describe('WebSocket downlinks', () => {
|
||||
it('carries mux and host over independent downstream sockets and cancels each source on close', async () => {
|
||||
let muxAborted = false
|
||||
let hostAborted = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
yield {
|
||||
rpcId: RpcId('mux-1'),
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 },
|
||||
}
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
muxAborted = true
|
||||
}
|
||||
},
|
||||
async function * (signal) {
|
||||
try {
|
||||
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
hostAborted = true
|
||||
}
|
||||
},
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
|
||||
const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`)
|
||||
const muxFrame = read(mux)
|
||||
const hostFrame = read(hostSocket)
|
||||
expect(await muxFrame).toEqual({
|
||||
type: 'server-request',
|
||||
rpcId: 'mux-1',
|
||||
method: 'session/subscribed',
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 },
|
||||
})
|
||||
expect(await hostFrame).toEqual({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-1',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
})
|
||||
|
||||
const muxClosed = once(mux, 'close')
|
||||
const hostClosed = once(hostSocket, 'close')
|
||||
mux.close()
|
||||
hostSocket.close()
|
||||
await Promise.all([muxClosed, hostClosed])
|
||||
await vi.waitFor(() => {
|
||||
expect(muxAborted).toBe(true)
|
||||
expect(hostAborted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects client messages because upstream remains HTTP', async () => {
|
||||
let aborted = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
aborted = true
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const closed = once(socket, 'close')
|
||||
socket.send('upstream payload')
|
||||
const [code, reason] = await closed as [number, Buffer]
|
||||
expect(code).toBe(1008)
|
||||
expect(String(reason)).toBe('downlink only')
|
||||
await vi.waitFor(() => { expect(aborted).toBe(true) })
|
||||
})
|
||||
|
||||
it('sends stream/error before closing when a source fails', async () => {
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * () {
|
||||
throw new Error('mux source failed')
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
const failure = read(socket)
|
||||
const closed = once(socket, 'close')
|
||||
expect((await failure).payload).toEqual({
|
||||
type: 'stream/error',
|
||||
error: { code: 'internal', message: 'Error: mux source failed', details: {} },
|
||||
})
|
||||
await closed
|
||||
})
|
||||
|
||||
it('aborts the source when an accepted socket reports a transport error', async () => {
|
||||
let aborted = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
aborted = true
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const accepted = await acceptedSocket(downlinks)
|
||||
const closed = once(socket, 'close')
|
||||
accepted.emit('error', new Error('transport failed'))
|
||||
await closed
|
||||
expect(aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('drops a source frame that races after the client has closed', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
let finish!: () => void
|
||||
const finished = new Promise<void>((resolve) => { finish = resolve })
|
||||
let sourceSignal: AbortSignal | undefined
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
sourceSignal = signal
|
||||
try {
|
||||
await gate
|
||||
yield {
|
||||
rpcId: RpcId('late'),
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 },
|
||||
}
|
||||
} finally {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const closed = once(socket, 'close')
|
||||
socket.close()
|
||||
await closed
|
||||
await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
|
||||
release()
|
||||
await finished
|
||||
})
|
||||
|
||||
it('contains socket send callback failures and closes the downlink', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * () {
|
||||
await gate
|
||||
yield {
|
||||
rpcId: RpcId('send-failure'),
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 },
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const accepted = await acceptedSocket(downlinks)
|
||||
const send = vi.spyOn(accepted, 'send').mockImplementation(((
|
||||
_data: unknown,
|
||||
optionsOrCallback?: unknown,
|
||||
callback?: (error?: Error) => void,
|
||||
) => {
|
||||
const done = typeof optionsOrCallback === 'function'
|
||||
? optionsOrCallback as (error?: Error) => void
|
||||
: callback
|
||||
done?.(new Error('socket send failed'))
|
||||
}) as WebSocket['send'])
|
||||
const closed = once(socket, 'close')
|
||||
release()
|
||||
await closed
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
send.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects when its acceptor has already closed', async () => {
|
||||
const downlinks = new WebSocketDownlinks(api(idle, idle))
|
||||
await downlinks.close()
|
||||
await expect(downlinks.close()).rejects.toThrow('The server is not running')
|
||||
})
|
||||
|
||||
it('waits for source cleanup before teardown resolves', async () => {
|
||||
let cleanupStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { cleanupStarted = resolve })
|
||||
let releaseCleanup!: () => void
|
||||
const cleanupGate = new Promise<void>((resolve) => { releaseCleanup = resolve })
|
||||
let cleaned = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
cleanupStarted()
|
||||
await cleanupGate
|
||||
cleaned = true
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
let closed = false
|
||||
const closing = host.close().then(() => { closed = true })
|
||||
try {
|
||||
await started
|
||||
expect(closed).toBe(false)
|
||||
releaseCleanup()
|
||||
await closing
|
||||
expect(cleaned).toBe(true)
|
||||
} finally {
|
||||
releaseCleanup()
|
||||
await closing
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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/hmr/README.md
|
||||
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
|
||||
README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4
|
||||
README.md: 454c03cc3cd11722943efd025d164d9ca8233d25
|
||||
README.zh.md: fc4100c48e5db9ed6781117dd652232bbd7c7aaa
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -17,5 +17,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and loud in the loader status projection; restoring the previous bundle automatically is deferred until a real need shows.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless (the bundle endpoint serves no-cache); rev refresh lands with the reconnect-handshake mechanism.
|
||||
- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
|
||||
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; reconnect is the only refresh boundary.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
为通过外部脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate`、`prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(连接 fiber、运行时 fiber 和 Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中明确显示;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新将在重新连接握手机制中实现。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中显示;系统不会自动恢复先前组合包。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害,因为组合包端点以 no-cache 提供内容;只有重新连接时才会刷新。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-hmr",
|
||||
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
|
||||
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -49,8 +49,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* client-hmr, browser half: hot-reload driver for client plugin entries.
|
||||
*
|
||||
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
|
||||
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
|
||||
* `rebuilt` frame it reloads the entry's bundle and swaps the cordis
|
||||
* fiber in place. Every graph entry is a plugin bundle under the web2 model
|
||||
* — `immediately` rows differ only in stage-one prefetch (a boot
|
||||
* optimization), so all rostered plugin packages share these reload semantics;
|
||||
@@ -14,7 +14,7 @@
|
||||
* cascades into its UI dependents with no HMR-side bookkeeping.
|
||||
*
|
||||
* Reload order (lazy CJS table): invalidate (drop the stale factory and
|
||||
* materialized record) → prefetch (fetch + execute + register the fresh
|
||||
* materialized record) → prefetch (load and register the fresh
|
||||
* factory) → registry-first teardown → drain old fiber unload → remove
|
||||
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
|
||||
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
|
||||
@@ -110,7 +110,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
// Invalidate first (drop stale factory + record — a live factory makes
|
||||
// prefetch a no-op and re-registration a loud duplicate), then run the
|
||||
// async half while the old fiber still serves: fetch + execute registers
|
||||
// async half while the old fiber still serves: script loading registers
|
||||
// the fresh factory with zero side effects (lazy CJS — module bodies run
|
||||
// at materialization, not execution).
|
||||
modLoader.invalidate(id)
|
||||
|
||||
@@ -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/locale/README.md
|
||||
README.md: 7f780092af9bc7079cc5080c06e986bef2dfdbce
|
||||
README.zh.md: 62c037977115d33b834fe60b042431e44d208524
|
||||
README.md: f1efefde4557e1c29c0556f8b670f1534430ab79
|
||||
README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291
|
||||
|
||||
@@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs.
|
||||
- **Some surfaces keep inline copy** — Settings rows, the sidebar, question composer, and model select use locale seats; other packages still own static text directly.
|
||||
- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.
|
||||
|
||||
@@ -10,9 +10,9 @@ locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包(package)既不组装也不发送提供方请求。
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移。
|
||||
- **部分界面仍保留内联文案**——设置行、侧边栏、问题作答器和模型选择使用 locale seat;其他包仍直接拥有静态文本。
|
||||
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。
|
||||
|
||||
@@ -51,9 +51,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
deferRegistration,
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -384,16 +383,12 @@ export function apply(ctx: ClientContext): void {
|
||||
setLocale: (id) => { locale.setLocale(id) },
|
||||
}
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'language',
|
||||
order: 0,
|
||||
store,
|
||||
locale: SETTINGS_NS,
|
||||
inject: injected,
|
||||
}, LanguageRow))
|
||||
return () => { deferred.dispose() }
|
||||
}, 'locale: language settings row registration')
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'language',
|
||||
order: 0,
|
||||
store,
|
||||
locale: SETTINGS_NS,
|
||||
inject: injected,
|
||||
}, LanguageRow))
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
|
||||
/** Empty global standard-kit hooks (the row reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
|
||||
@@ -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/modules/README.md
|
||||
README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
|
||||
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f
|
||||
README.md: 7d661c806955d0fac021dd6620994aab83c0f773
|
||||
README.zh.md: a1da42a552dbe8770fcb78bf458c01a0057ce8dd
|
||||
|
||||
@@ -6,9 +6,9 @@ Client module system: the browser peer of Node's internal ESM loader, built as a
|
||||
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
|
||||
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
惰性 CJS 模型(web2):执行插件组合包只会注册其 factory(`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块,系统会递归物化它,因此加载顺序无需外部编排;require 循环会抛出异常(factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
|
||||
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。
|
||||
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR(热模块替换)钩子。
|
||||
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
*
|
||||
* Resolution branch order (import): seed word → shell instance; memoized
|
||||
* record → surface; static registry (shell-own modules, e.g. app-shell) →
|
||||
* module; registered factory → materialize; graph row → fetch + execute +
|
||||
* materialize; anything else → throw (loud — the runtime mirror of the
|
||||
* module; registered factory → materialize; graph row → load + materialize;
|
||||
* anything else → throw (loud — the runtime mirror of the
|
||||
* build-time bundle purity gate). The synchronous `require` handed to
|
||||
* factories walks the same order minus the fetch branch: fetching is async,
|
||||
* so only already-executed bundles can be required — and cross-plugin value
|
||||
* factories walks the same order minus the load branch: loading is async,
|
||||
* so only already-registered bundles can be required — and cross-plugin value
|
||||
* imports are a build error anyway.
|
||||
*
|
||||
* This file is the browser-safe contract face (zero node imports): the
|
||||
@@ -56,7 +56,7 @@ export interface WebBootEntry {
|
||||
rev: string
|
||||
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
|
||||
inject?: string[]
|
||||
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
|
||||
/** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
|
||||
immediately?: boolean
|
||||
}
|
||||
|
||||
@@ -210,18 +210,17 @@ export interface ClientModuleLoader {
|
||||
*/
|
||||
registerStatic(id: string, module: unknown): void
|
||||
/**
|
||||
* Stage-one arrival: fetch the entry's bundle and execute it, registering
|
||||
* its factory (no materialization — module side effects wait for import).
|
||||
* Stage-one arrival: load the entry's script to register its factory (no
|
||||
* materialization — module side effects wait for import).
|
||||
* No-op for static-registered ids and ids whose factory is already
|
||||
* registered; concurrent calls share one in-flight task. To force a fresh
|
||||
* fetch (HMR), {@link invalidate} first.
|
||||
* load (HMR), {@link invalidate} first.
|
||||
* @param id - graph entry name.
|
||||
*/
|
||||
prefetch(id: string): Promise<void>
|
||||
/**
|
||||
* Full reset of one module: drop its registered factory, its materialized
|
||||
* record, and any consumed bundle text, so the next prefetch/import
|
||||
* refetches and re-executes (the HMR invalidation hook).
|
||||
* Full reset of one module: drop its registered factory and materialized
|
||||
* record so the next prefetch/import reloads it (the HMR invalidation hook).
|
||||
* @param id - entry name to invalidate.
|
||||
*/
|
||||
invalidate(id: string): void
|
||||
@@ -233,11 +232,6 @@ export interface ClientModuleSystemOptions {
|
||||
modules: BootModuleRow[]
|
||||
/** Module-table seed: platform-singleton specifier → shell instance. */
|
||||
staticModules: Record<string, unknown>
|
||||
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
|
||||
fetchBundle?: (url: string) => Promise<string>
|
||||
/**
|
||||
* Bundle execution seam (synchronously performs the load() registration).
|
||||
* Defaults to a <script> element carrying the code.
|
||||
*/
|
||||
executeBundle?: (code: string, url: string) => void
|
||||
/** Bundle-load seam. Defaults to a same-origin classic `<script src>` element. */
|
||||
loadBundle?: (url: string) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -2,38 +2,28 @@
|
||||
* ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
|
||||
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
|
||||
* documented on the public interfaces in `./manifest.ts`; this file owns the
|
||||
* state tables and the fetch/execute/materialize machinery.
|
||||
* state tables and the load/materialize machinery.
|
||||
*/
|
||||
import type {
|
||||
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
|
||||
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
|
||||
} from './manifest.ts'
|
||||
|
||||
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
|
||||
interface RegisteredFactory {
|
||||
factory: ClientPluginHandoff['factory']
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Default bundle fetch seam: same-origin fetch().text(). */
|
||||
const defaultFetchBundle = async (url: string): Promise<string> => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
/** Default bundle execution seam: a <script> element carrying the code. */
|
||||
const defaultExecuteBundle = (code: string, url: string): void => {
|
||||
/** Default bundle-load seam: same-origin external classic script. */
|
||||
const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve, reject) => {
|
||||
const el = document.createElement('script')
|
||||
// Inline execution (not src) so the fetch half stays parallelizable; the
|
||||
// sourceURL comment keeps devtools stack frames attributed to the bundle.
|
||||
el.textContent = `${code}\n//# sourceURL=${url}`
|
||||
document.head.appendChild(el)
|
||||
// Execution is synchronous for inline scripts: the factory is registered by
|
||||
// now, so the node (and its source text) has no further job. Removing it
|
||||
// keeps repeated HMR rebuilds from accumulating dead script nodes.
|
||||
el.remove()
|
||||
}
|
||||
el.async = true
|
||||
el.src = url
|
||||
el.addEventListener('load', () => {
|
||||
el.remove()
|
||||
resolve()
|
||||
}, { once: true })
|
||||
el.addEventListener('error', () => {
|
||||
el.remove()
|
||||
reject(new Error(`client-modules: bundle script ${url} failed to load`))
|
||||
}, { once: true })
|
||||
document.head.append(el)
|
||||
})
|
||||
|
||||
/**
|
||||
* A plugin bundle IS its package's client half: `<id>/client` (the exports
|
||||
@@ -72,31 +62,21 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
|
||||
private readonly seed: Map<string, unknown>
|
||||
private readonly statics = new Map<string, unknown>()
|
||||
private readonly factories = new Map<string, RegisteredFactory>()
|
||||
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
|
||||
private readonly factories = new Map<string, ClientPluginHandoff['factory']>()
|
||||
/** In-flight prefetch (script load) per id; concurrent callers share it. */
|
||||
private readonly pendingArrival = new Map<string, Promise<void>>()
|
||||
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
|
||||
private readonly materializing = new Set<string>()
|
||||
private readonly graphRows = new Map<string, BootModuleRow>()
|
||||
// Execution URL of the bundle currently being executed (bound into the
|
||||
// factory registration so diagnostics can name the source).
|
||||
private executingUrl = ''
|
||||
// Graph id of the row currently being executed ('' outside arrive):
|
||||
// the load sink cross-checks the handoff id against it so a mis-stamped
|
||||
// bundle cannot register under another entry's identity.
|
||||
private executingId = ''
|
||||
|
||||
private readonly fetchBundle: (url: string) => Promise<string>
|
||||
private readonly executeBundle: (code: string, url: string) => void
|
||||
private readonly loadBundle: (url: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* Build the module system over the parsed boot rows.
|
||||
* @param options - module rows, module-table staticModules, fetch/execute seams.
|
||||
* @param options - module rows, module-table staticModules, and bundle-load seam.
|
||||
*/
|
||||
constructor(options: ClientModuleSystemOptions) {
|
||||
this.seed = new Map(Object.entries(options.staticModules))
|
||||
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
|
||||
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
|
||||
this.loadBundle = options.loadBundle ?? defaultLoadBundle
|
||||
|
||||
for (const row of options.modules) {
|
||||
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
|
||||
@@ -110,37 +90,22 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
// Registration is keyed by the handoff id; a duplicate means a bundle
|
||||
// executed twice without an invalidate — always a bug, always loud.
|
||||
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
|
||||
// A fetched row's bundle must register the id its row names — a
|
||||
// mis-stamped bundle registering under another entry's identity
|
||||
// would let that entry silently materialize foreign exports.
|
||||
if (this.executingId !== '' && handoff.id !== this.executingId) {
|
||||
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
|
||||
}
|
||||
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
|
||||
this.factories.set(handoff.id, handoff.factory)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
|
||||
/** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
|
||||
private arrive(row: BootModuleRow): Promise<void> {
|
||||
const { id, url } = row
|
||||
const pending = this.pendingArrival.get(id)
|
||||
if (pending !== undefined) return pending
|
||||
if (this.factories.has(id)) return Promise.resolve()
|
||||
const task = (async (): Promise<void> => {
|
||||
const code = await this.fetchBundle(url)
|
||||
this.executingUrl = url
|
||||
this.executingId = id
|
||||
try {
|
||||
this.executeBundle(code, url)
|
||||
} finally {
|
||||
this.executingUrl = ''
|
||||
this.executingId = ''
|
||||
}
|
||||
const task = this.loadBundle(url).then(() => {
|
||||
if (!this.factories.has(id)) {
|
||||
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
|
||||
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
|
||||
}
|
||||
})().finally(() => { this.pendingArrival.delete(id) })
|
||||
}).finally(() => { this.pendingArrival.delete(id) })
|
||||
this.pendingArrival.set(id, task)
|
||||
return task
|
||||
}
|
||||
@@ -158,7 +123,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
this.materializing.add(id)
|
||||
try {
|
||||
const edges = new Set<string>()
|
||||
const surface = registered.factory(this.makeRequire(edges))
|
||||
const surface = registered(this.makeRequire(edges))
|
||||
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
|
||||
this.loadCache.set(id, record)
|
||||
return record
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Node half of the client module system (dshClient dual-face package): scans
|
||||
* the host Loader's entries for `dshClient` packages, composes the
|
||||
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
|
||||
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
|
||||
* index render to inject the boot manifest, and provides the
|
||||
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
|
||||
* map, taps the index render to inject the boot manifest, and provides the
|
||||
* `clientModuleHost` service (the HMR node half's registration/notification
|
||||
* face).
|
||||
*
|
||||
@@ -424,9 +424,15 @@ export class ClientModuleHostService extends Service {
|
||||
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
|
||||
// The id may contain a scope slash. Anything else under /plugins (including
|
||||
// /plugins/events when the HMR row is absent) is an unknown resource.
|
||||
const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
|
||||
? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
|
||||
const prefix = '/plugins/'
|
||||
const mapSuffix = '/client.js.map'
|
||||
const bundleSuffix = '/client.js'
|
||||
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
|
||||
const suffix = isSourceMap ? mapSuffix : bundleSuffix
|
||||
const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
|
||||
? this.clientPath(pathname.slice(prefix.length, -suffix.length))
|
||||
: undefined
|
||||
const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
|
||||
if (path === undefined) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
@@ -434,7 +440,10 @@ export class ClientModuleHostService extends Service {
|
||||
}
|
||||
try {
|
||||
const body = await readFile(path)
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
|
||||
res.writeHead(200, {
|
||||
'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* registers the factory), materialization on first import/require with
|
||||
* memoization and recursive self-sequencing, the resolution branch order,
|
||||
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
|
||||
* default transport seams, and the loud failure modes (duplicate
|
||||
* default transport seam, and the loud failure modes (duplicate
|
||||
* registration, cycles, table misses, double boot).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -20,7 +20,6 @@ type Factory = ClientPluginHandoff['factory']
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
delete win.__ModuleLoader__
|
||||
delete (document as unknown as Record<string, unknown>).__realmBridge
|
||||
for (const el of document.querySelectorAll('style, script')) el.remove()
|
||||
})
|
||||
|
||||
@@ -33,9 +32,9 @@ interface Bench {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader over scripted bundles: fetch resolves to the row url (optionally
|
||||
* gated on a release callback); execute registers the scripted factory
|
||||
* through the window sink (`null` scripts a bundle that never calls load).
|
||||
* Loader over scripted bundles: load records the row URL, optionally waits on
|
||||
* a release callback, then registers the scripted factory through the window
|
||||
* sink (`null` scripts a bundle that never calls load).
|
||||
*/
|
||||
function bench(
|
||||
entries: BootModuleRow[],
|
||||
@@ -47,15 +46,12 @@ function bench(
|
||||
const loader = new ClientModuleSystem({
|
||||
modules: entries,
|
||||
staticModules: opts.seed ?? {},
|
||||
fetchBundle: (url) => {
|
||||
loadBundle: async (url) => {
|
||||
fetched.push(url)
|
||||
if (opts.gated?.includes(url) === true) {
|
||||
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
|
||||
await new Promise<void>((resolve) => { gates.set(url, resolve) })
|
||||
}
|
||||
return Promise.resolve(url)
|
||||
},
|
||||
executeBundle: (code) => {
|
||||
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
|
||||
const id = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
|
||||
const factory = id === undefined ? undefined : bundles[id]
|
||||
if (factory == null || id === undefined) return
|
||||
win.__ModuleLoader__?.load({ id, factory })
|
||||
@@ -65,7 +61,7 @@ function bench(
|
||||
}
|
||||
|
||||
describe('lazy CJS arrival', () => {
|
||||
it('prefetch fetches and executes but does not run the factory', async () => {
|
||||
it('prefetch loads and registers but does not run the factory', async () => {
|
||||
const ran: string[] = []
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
|
||||
await b.loader.prefetch('a')
|
||||
@@ -85,7 +81,7 @@ describe('lazy CJS arrival', () => {
|
||||
expect(b.loader.loadCache.get('a')?.id).toBe('a')
|
||||
})
|
||||
|
||||
it('import without prefetch fetches, executes, and materializes in one call', async () => {
|
||||
it('import without prefetch loads, registers, and materializes in one call', async () => {
|
||||
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('direct')
|
||||
@@ -228,7 +224,7 @@ describe('failure modes', () => {
|
||||
})
|
||||
|
||||
describe('HMR reset', () => {
|
||||
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
|
||||
it('invalidate drops the factory and record so the module reloads and re-registers', async () => {
|
||||
let generation = 0
|
||||
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
|
||||
const first = await b.loader.import('a', '', {})
|
||||
@@ -275,27 +271,35 @@ describe('style claiming', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('default transport seams', () => {
|
||||
it('fetches same-origin and executes through an inline script tag', async () => {
|
||||
// In a browser the loader's globalThis IS the page window; vitest's jsdom
|
||||
// evaluates <script> in a separate realm that shares only the document,
|
||||
// so the fixture bundle restores the sink from a document bridge before
|
||||
// using the normal calling convention.
|
||||
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
|
||||
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
|
||||
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
|
||||
describe('default transport seam', () => {
|
||||
it('loads through an external classic script and removes the settled node', async () => {
|
||||
const append = vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
|
||||
const script = nodes[0]
|
||||
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
|
||||
expect(script.async).toBe(true)
|
||||
expect(script.getAttribute('src')).toBe('/plugins/dee/client.js?rev=0')
|
||||
queueMicrotask(() => {
|
||||
win.__ModuleLoader__?.load({ id: 'dee', factory: () => ({ marker: 'via-script' }) })
|
||||
script.dispatchEvent(new Event('load'))
|
||||
})
|
||||
})
|
||||
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
|
||||
const surface = await loader.import('dee', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('via-script')
|
||||
// The script node is removed right after its synchronous execution —
|
||||
// repeated HMR rebuilds must not accumulate dead script nodes.
|
||||
expect(append).toHaveBeenCalledOnce()
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
|
||||
it('a non-ok bundle response is loud with the status', async () => {
|
||||
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
|
||||
it('a script load failure is loud and removes the node', async () => {
|
||||
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
|
||||
const script = nodes[0]
|
||||
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
|
||||
queueMicrotask(() => { script.dispatchEvent(new Event('error')) })
|
||||
})
|
||||
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
|
||||
await expect(loader.prefetch('dee')).rejects.toThrow(
|
||||
'bundle script /plugins/dee/client.js?rev=0 failed to load',
|
||||
)
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/** Node-half composition diagnostics for package metadata and built client bundles. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { ClientModuleHostService } from '../src/index.ts'
|
||||
|
||||
let root: string | undefined
|
||||
@@ -33,8 +34,8 @@ function writePackage(packageName: string): string {
|
||||
return clientPath
|
||||
}
|
||||
|
||||
/** Construct the node-half service over the enabled fixture entries. */
|
||||
function construct(packageNames: string[]): ClientModuleHostService {
|
||||
/** Construct the node-half service and capture its plugin-bundle route. */
|
||||
function constructWithRoute(packageNames: string[]): { service: ClientModuleHostService; route: WebRoute } {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root!).href + '/'
|
||||
ctx.provide('loader', {
|
||||
@@ -44,13 +45,24 @@ function construct(packageNames: string[]): ClientModuleHostService {
|
||||
}
|
||||
},
|
||||
})
|
||||
let route: WebRoute | undefined
|
||||
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
|
||||
port: 0,
|
||||
register: () => () => {},
|
||||
register: (candidate) => {
|
||||
if (candidate.path === '/plugins') route = candidate
|
||||
return () => {}
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
}
|
||||
ctx.provide('httpServer', httpServer as HttpServerService)
|
||||
return new ClientModuleHostService(ctx)
|
||||
const service = new ClientModuleHostService(ctx)
|
||||
if (route === undefined) throw new Error('client bundle route was not registered')
|
||||
return { service, route }
|
||||
}
|
||||
|
||||
/** Construct the node-half service over the enabled fixture entries. */
|
||||
function construct(packageNames: string[]): ClientModuleHostService {
|
||||
return constructWithRoute(packageNames).service
|
||||
}
|
||||
|
||||
describe('client bundle activation', () => {
|
||||
@@ -84,4 +96,40 @@ describe('client bundle activation', () => {
|
||||
expect(String(thrown)).toContain('EISDIR')
|
||||
expect(String(thrown)).not.toContain('pnpm run build')
|
||||
})
|
||||
|
||||
it('serves the source map beside a registered client bundle', async () => {
|
||||
const packageName = '@fixture/source-map'
|
||||
const clientPath = writePackage(packageName)
|
||||
mkdirSync(dirname(clientPath), { recursive: true })
|
||||
writeFileSync(clientPath, 'module.exports = {}\n')
|
||||
const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
|
||||
writeFileSync(`${clientPath}.map`, map)
|
||||
const { route } = constructWithRoute([packageName])
|
||||
let status = 0
|
||||
let headers: Record<string, string> | undefined
|
||||
let body = ''
|
||||
const response = {
|
||||
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
|
||||
status = nextStatus
|
||||
headers = nextHeaders
|
||||
return response
|
||||
},
|
||||
end(chunk?: Uint8Array) {
|
||||
body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
|
||||
return response
|
||||
},
|
||||
} as unknown as ServerResponse
|
||||
|
||||
await route.handler({
|
||||
method: 'GET',
|
||||
url: `/plugins/${packageName}/client.js.map`,
|
||||
} as IncomingMessage, response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(headers).toEqual({
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
expect(body).toBe(map)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: 0ae71ff17b13be67c16786ff69a0e1626437913a
|
||||
README.zh.md: 52a443d9df753ba01650b6cbf189c39633f6a461
|
||||
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
|
||||
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
|
||||
|
||||
@@ -2,12 +2,20 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
## Slot declaration injection
|
||||
|
||||
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
|
||||
|
||||
The callback returns one synchronous disposer or an iterable of disposers. A generator can therefore yield several `slots.register()` calls as one transaction: setup failure rolls earlier yields back and teardown runs them in reverse order. Declaration lifetimes use a dedicated monotonic epoch, so a collapse and redeclaration batched into one renderer notification still restarts the callback, while ordinary entry changes do not. Declaration-bound teardown runs synchronously with the ledger mutation, releasing runtime resources before subsequent same-tick registrations. See the [declaration-injection decision](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md).
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
|
||||
`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending.
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
|
||||
|
||||
`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.
|
||||
@@ -18,15 +26,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
## 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.
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `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.
|
||||
|
||||
## Pending queue projection
|
||||
|
||||
`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
|
||||
`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.
|
||||
`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. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. 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. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. 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).
|
||||
|
||||
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.
|
||||
|
||||
@@ -64,6 +72,6 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
|
||||
- **`loader.unload` is a stub** — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
|
||||
|
||||
@@ -2,12 +2,20 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
## Slot 声明注入
|
||||
|
||||
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
|
||||
|
||||
回调返回一个同步 disposer 或由多个 disposer 构成的 iterable。因此,generator 可以 yield 多个 `slots.register()` 调用,并将它们组成一项事务:setup 失败会回滚先前 yield 的 effect,teardown 则按逆序运行它们。声明生命周期使用专用的单调 declaration epoch(声明代次),因此,即使折叠与重新声明合并在同一次 renderer 通知中,回调仍会重启,而普通条目变更不会重启它。声明绑定的 teardown 与账本变更同步运行,在同一 tick 内的后续注册之前释放运行时资源。详见 [slot 声明注入决策](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md)。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
|
||||
`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
|
||||
|
||||
`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 中。
|
||||
@@ -18,15 +26,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## 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`)。
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `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`)。
|
||||
|
||||
## 待处理队列投影
|
||||
|
||||
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。
|
||||
`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` 从行为侧覆盖同一漂移。
|
||||
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
|
||||
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
|
||||
@@ -44,7 +52,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
## 模型重试投影
|
||||
|
||||
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
|
||||
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
|
||||
|
||||
## 会话 fork
|
||||
|
||||
@@ -64,6 +72,6 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
|
||||
- **`loader.unload` 是 stub**:它会抛出 not-implemented;客户端没有从 fiber dispose 到注册与样式移除的卸载链。
|
||||
- **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)所记录的问题。
|
||||
|
||||
@@ -59,8 +59,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface SessionHistorySnapshot {
|
||||
state: 'cold' | 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
hasMore: boolean
|
||||
/** Absolute sequence of the first loaded raw event, or zero for an empty window. */
|
||||
baseSeq: number
|
||||
inspection: SessionHistoryInspection
|
||||
}
|
||||
|
||||
@@ -17,11 +19,17 @@ export interface SessionHistoryFace
|
||||
extends ObservableSnapshot<SessionHistorySnapshot> {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Load the tail and exhaust every available older page.
|
||||
* @param signal - Consumer lifetime; abort is observed between page requests.
|
||||
* @returns When the available ledger is complete or stops advancing.
|
||||
* Load the current tail without reading older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When the tail is ready or loading fails.
|
||||
*/
|
||||
loadAll(signal?: AbortSignal): Promise<void>
|
||||
loadTail(signal?: AbortSignal): Promise<void>
|
||||
/**
|
||||
* Prepend one older page when the current window has a predecessor.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns Whether the loaded window advanced.
|
||||
*/
|
||||
loadOlder(signal?: AbortSignal): Promise<boolean>
|
||||
}
|
||||
|
||||
/** Runtime service resolving independent history sources. */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type {
|
||||
InboxItemId, QueueAction, RpcResult, SessionId,
|
||||
MessageId, QueueAction, RpcResult, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationSnapshot } from '../sessions/conversation.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
@@ -39,12 +39,12 @@ export interface ISession {
|
||||
*/
|
||||
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Apply one mutation to a still-pending queue occurrence.
|
||||
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
* @param action - edit or remove operation.
|
||||
* @param action - requested queue operation.
|
||||
* @returns acceptance, or a business/transport error.
|
||||
*/
|
||||
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
|
||||
updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Cancel the running turn. Pending queued work remains and resumes in FIFO
|
||||
* order after the Host reaches cancellation quiescence.
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
RpcResult, SessionId, SubagentAddress,
|
||||
} 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 {
|
||||
@@ -34,6 +36,29 @@ export interface ISessions {
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
*/
|
||||
open(id: SessionId): void
|
||||
/**
|
||||
* Open a healthy catalog child through its exact direct-parent address.
|
||||
* @param address - catalog-derived parent and child ids.
|
||||
*/
|
||||
openSubagent(address: SubagentAddress): void
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
* @param id - possible addressed child id.
|
||||
* @returns the retained address, when present.
|
||||
*/
|
||||
subagentAddress(id: SessionId): SubagentAddress | undefined
|
||||
/**
|
||||
* Mark whether a catalog menu is consuming live membership updates.
|
||||
* @param parentSessionId - catalog owner.
|
||||
* @param open - current menu state.
|
||||
*/
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
* @param parentSessionId - catalog owner.
|
||||
* @returns completion of the current or newly started refresh.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void>
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,8 @@ export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
|
||||
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type {
|
||||
@@ -52,13 +53,18 @@ export type {
|
||||
export type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from './sessions/conversation-context.ts'
|
||||
export type {
|
||||
ContextProvenanceView, ContextRole, KnownContextForm,
|
||||
} from './sessions/context-provenance.ts'
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from './sessions/request-inspection.ts'
|
||||
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
|
||||
export type { SessionHistoryInspection } from './sessions/history.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
export type {
|
||||
PendingInteraction, PendingInteractionStatus, PendingKind, PendingPayloads,
|
||||
} from './sessions/pending.ts'
|
||||
// Projection value store (session-projection RFC, push model): host-computed
|
||||
// whole values per key; domains ship projection support with zero client code.
|
||||
export type {
|
||||
|
||||
@@ -11,11 +11,15 @@ import type {
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
|
||||
import { SteeringHistory } from '../sessions/steering-history.ts'
|
||||
import type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from '../sessions/conversation-context.ts'
|
||||
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
@@ -30,11 +34,6 @@ interface FoldedContext {
|
||||
originSeq?: number
|
||||
}
|
||||
|
||||
interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/** Immutable conversation projections derived only from the history source. */
|
||||
export interface ConversationHistoryProjection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
@@ -45,22 +44,10 @@ export interface ConversationHistoryProjection {
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
// Trajectory owns surface-window reconstruction so its immutable ledger does
|
||||
// not depend on Chat's live fold adapter or Session's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
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
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
|
||||
if (event?.type !== 'user/message') return 'rewrite'
|
||||
@@ -72,39 +59,61 @@ function contextOriginKind(event: SessionEvent | undefined): ConversationContext
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const originalSeqs: number[] = []
|
||||
const rebasedSeqByOriginal = new Map<number, number>()
|
||||
const surface = new SurfaceManager(replay)
|
||||
const contexts: FoldedContext[] = []
|
||||
let generation = 0
|
||||
let originSeq: number | undefined
|
||||
const originalNodes = () => surface.nodes.map((seq) => {
|
||||
const original = originalSeqs[seq]
|
||||
if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
|
||||
return original
|
||||
})
|
||||
for (const event of events) {
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
if (!isSurfaceEvent(event)) continue
|
||||
if (event.surfaceOp !== 'append') {
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
nodes: originalNodes(),
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
generation++
|
||||
originSeq = event.seq
|
||||
}
|
||||
replay.push(event)
|
||||
const rebasedSeq = replay.length
|
||||
const {
|
||||
sourceEventSeqs: rawSources,
|
||||
...eventWithoutSources
|
||||
} = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
|
||||
const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
|
||||
const rebased = rebasedSeqByOriginal.get(seq)
|
||||
return rebased === undefined ? [] : [rebased]
|
||||
})
|
||||
const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
|
||||
? undefined
|
||||
: mappedSourceEventSeqs
|
||||
const surfaceOp = event.surfaceOp === 'append'
|
||||
? event.surfaceOp
|
||||
: {
|
||||
...event.surfaceOp,
|
||||
start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
|
||||
end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
|
||||
}
|
||||
originalSeqs.push(event.seq)
|
||||
rebasedSeqByOriginal.set(event.seq, rebasedSeq)
|
||||
replay.push({
|
||||
...eventWithoutSources,
|
||||
seq: rebasedSeq,
|
||||
surfaceOp,
|
||||
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
|
||||
} as SessionEvent)
|
||||
}
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
nodes: originalNodes(),
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
return contexts
|
||||
@@ -119,6 +128,7 @@ function materializeNode(
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming: AssistantTiming | undefined,
|
||||
requestConfig: AssistantRequestConfig | undefined,
|
||||
steering: boolean,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
@@ -126,6 +136,15 @@ function materializeNode(
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
provenance: contextProvenance(event.data.source),
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
if (steering) {
|
||||
return {
|
||||
kind: 'steering', messageId: event.data.id,
|
||||
seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -144,11 +163,6 @@ function materializeNode(
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
|
||||
}
|
||||
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)
|
||||
@@ -330,11 +344,13 @@ export function projectConversationHistory(
|
||||
entries: readonly HistoryEntry[],
|
||||
): ConversationHistoryProjection {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const steeringHistory = new SteeringHistory()
|
||||
const steeringSeqs = new Set<number>()
|
||||
for (const event of events) {
|
||||
if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
|
||||
}
|
||||
const baseSeq = events[0]?.seq ?? 0
|
||||
const padded = [
|
||||
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
|
||||
...events,
|
||||
]
|
||||
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
|
||||
const callIndex = new Map<string, CallIndexEntry>()
|
||||
const resultViews = new Map<number, ToolResultView>()
|
||||
const assistantSteps = new Map<string, AssistantStepMetadata>()
|
||||
@@ -361,6 +377,7 @@ export function projectConversationHistory(
|
||||
contextGeneration++
|
||||
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
|
||||
}
|
||||
indexAssistantStepTiming(assistantSteps, event)
|
||||
if (event.type === 'request/header') {
|
||||
activeRequestConfig = event.data.header.config
|
||||
activePrompt = {
|
||||
@@ -369,30 +386,10 @@ export function projectConversationHistory(
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
promptsByContext.set(contextGeneration, activePrompt)
|
||||
} else if (event.type === 'step/start') {
|
||||
assistantSteps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = assistantSteps.get(key) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}
|
||||
if (current.firstTokenTime === null) {
|
||||
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
} else if (event.type === 'assistant/message') {
|
||||
assistantTimings.set(
|
||||
event.seq,
|
||||
{
|
||||
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}),
|
||||
completedTime: event.time,
|
||||
},
|
||||
settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time),
|
||||
)
|
||||
if (activeRequestConfig !== undefined) {
|
||||
assistantRequestConfigs.set(event.seq, activeRequestConfig)
|
||||
@@ -404,7 +401,7 @@ export function projectConversationHistory(
|
||||
const materialize = (seq: number): ConversationNode | undefined => {
|
||||
const cached = nodeCache.get(seq)
|
||||
if (cached !== undefined) return cached
|
||||
const event = padded[seq]
|
||||
const event = eventsBySeq.get(seq)
|
||||
if (event === undefined || !isSurfaceEligibleType(event.type)) return
|
||||
const node = materializeNode(
|
||||
event,
|
||||
@@ -412,6 +409,7 @@ export function projectConversationHistory(
|
||||
resultViews.get(seq) ?? null,
|
||||
assistantTimings.get(seq),
|
||||
assistantRequestConfigs.get(seq),
|
||||
steeringSeqs.has(seq),
|
||||
)
|
||||
nodeCache.set(seq, node)
|
||||
return node
|
||||
@@ -430,7 +428,7 @@ export function projectConversationHistory(
|
||||
}]
|
||||
} else {
|
||||
try {
|
||||
contexts = foldContexts(padded).map((context): ConversationContext => {
|
||||
contexts = foldContexts(events).map((context): ConversationContext => {
|
||||
const nodes = context.nodes.flatMap((seq) => {
|
||||
const node = materialize(seq)
|
||||
return node === undefined ? [] : [node]
|
||||
@@ -443,7 +441,7 @@ export function projectConversationHistory(
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
const originEvent = padded[context.originSeq]
|
||||
const originEvent = eventsBySeq.get(context.originSeq)
|
||||
return {
|
||||
id: context.generation,
|
||||
parentId: context.generation - 1,
|
||||
|
||||
@@ -6,9 +6,11 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from '../contract/session-history.ts'
|
||||
import { createHistoryInspection } from '../sessions/history.ts'
|
||||
import {
|
||||
compactHistoryInspectionEntries, createHistoryInspection,
|
||||
} from '../sessions/history.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
|
||||
|
||||
const HISTORY_PAGE_MESSAGES = 50
|
||||
|
||||
@@ -18,7 +20,8 @@ function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
|
||||
/** Independent raw-history owner used only by inspection consumers. */
|
||||
export class SessionHistorySource implements SessionHistoryFace {
|
||||
private entries: readonly HistoryEntry[] = []
|
||||
private entries: HistoryEntry[] = []
|
||||
private inspectionEntries: readonly HistoryEntry[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private state: SessionHistorySnapshot['state'] = 'cold'
|
||||
@@ -36,7 +39,6 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
value: SessionHistorySnapshot['inspection']
|
||||
} | null = null
|
||||
private streamPublishToken: object | null = null
|
||||
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
|
||||
private streamPartial: PartialAccumulator | null = null
|
||||
private snapshotCache: SessionHistorySnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
@@ -73,37 +75,29 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the tail and exhaust all available older pages.
|
||||
* Load the current tail without reading older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When paging completes, fails to advance, or is aborted.
|
||||
* @returns When the tail is ready or loading fails.
|
||||
*/
|
||||
async loadAll(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted === true) return
|
||||
async loadTail(signal?: AbortSignal): Promise<void> {
|
||||
if (isAborted(signal)) return
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
while (
|
||||
!isAborted(signal)
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
|
||||
private async loadForConsumers(): Promise<void> {
|
||||
/**
|
||||
* Prepend one older page when the current window has a predecessor.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns Whether the loaded window advanced.
|
||||
*/
|
||||
async loadOlder(signal?: AbortSignal): Promise<boolean> {
|
||||
if (isAborted(signal)) return false
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
while (
|
||||
this.hasConsumer()
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
if (isAborted(signal)) return false
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlderPage()
|
||||
return this.baseSeq !== previousBaseSeq
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,12 +138,13 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
this.entries = []
|
||||
this.inspectionEntries = []
|
||||
this.baseSeq = 0
|
||||
this.hasMore = false
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.publishDirtyNow()
|
||||
void this.loadForConsumers()
|
||||
void this.open()
|
||||
}
|
||||
|
||||
/** Stop future refresh work after the host removes the session. */
|
||||
@@ -161,7 +156,6 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
this.olderPromise = null
|
||||
this.liveBuffer = []
|
||||
this.streamPublishToken = null
|
||||
this.streamBaseInspection = null
|
||||
this.streamPartial = null
|
||||
}
|
||||
|
||||
@@ -234,7 +228,7 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
}
|
||||
}
|
||||
|
||||
private loadOlder(): Promise<void> {
|
||||
private loadOlderPage(): Promise<void> {
|
||||
if (this.olderPromise !== null) return this.olderPromise
|
||||
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
|
||||
const generation = this.generation
|
||||
@@ -260,6 +254,7 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
return
|
||||
}
|
||||
this.entries = [...older, ...this.entries]
|
||||
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
} catch (error) {
|
||||
@@ -291,6 +286,7 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
this.entries = [...prefix, ...tail]
|
||||
}
|
||||
this.baseSeq = this.entries[0]?.event.seq ?? 0
|
||||
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const entry of buffered) this.appendLive(entry)
|
||||
@@ -324,7 +320,11 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
private appendLive(entry: HistoryEntry): void {
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq <= tailSeq) return
|
||||
this.entries = [...this.entries, entry]
|
||||
this.entries.push(entry)
|
||||
this.inspectionEntries = [...this.inspectionEntries, entry]
|
||||
if (entry.event.type === 'assistant/message') {
|
||||
this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries)
|
||||
}
|
||||
}
|
||||
|
||||
/** Append a chunk against the cached finalized projection; false means no visible publish. */
|
||||
@@ -336,11 +336,10 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
if (!isVisibleAssistantChunk(chunk.type)) {
|
||||
const inspection = this.currentInspection()
|
||||
this.appendLive(entry)
|
||||
this.inspectionCache = { entries: this.entries, value: inspection }
|
||||
this.inspectionCache = { entries: this.inspectionEntries, value: inspection }
|
||||
return false
|
||||
}
|
||||
const base = this.streamBaseInspection ?? this.currentInspection()
|
||||
this.streamBaseInspection = base
|
||||
const base = this.currentInspection()
|
||||
if (
|
||||
this.streamPartial === null
|
||||
|| this.streamPartial.turn !== turn
|
||||
@@ -356,7 +355,7 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
this.streamPartial.push(chunk)
|
||||
this.appendLive(entry)
|
||||
this.inspectionCache = {
|
||||
entries: this.entries,
|
||||
entries: this.inspectionEntries,
|
||||
value: { ...base, partial: this.streamPartial.toPartial() },
|
||||
}
|
||||
return true
|
||||
@@ -382,7 +381,6 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
|
||||
private publishDirtyNow(): void {
|
||||
this.streamPublishToken = null
|
||||
this.streamBaseInspection = null
|
||||
this.streamPartial = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
@@ -415,14 +413,15 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
state: this.state,
|
||||
error: this.error,
|
||||
hasMore: this.hasMore,
|
||||
baseSeq: this.baseSeq,
|
||||
inspection: this.currentInspection(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspection pinned to the source's current immutable entry array. */
|
||||
private currentInspection(): SessionHistorySnapshot['inspection'] {
|
||||
if (this.inspectionCache?.entries !== this.entries) {
|
||||
const entries = this.entries
|
||||
if (this.inspectionCache?.entries !== this.inspectionEntries) {
|
||||
const entries = this.inspectionEntries
|
||||
this.inspectionCache = {
|
||||
entries,
|
||||
value: createHistoryInspection(() => entries),
|
||||
@@ -431,11 +430,3 @@ export class SessionHistorySource implements SessionHistoryFace {
|
||||
return this.inspectionCache.value
|
||||
}
|
||||
}
|
||||
|
||||
function isVisibleAssistantChunk(type: string): boolean {
|
||||
return type === 'block-start'
|
||||
|| type === 'text-delta'
|
||||
|| type === 'reasoning-delta'
|
||||
|| type === 'tool-call-delta'
|
||||
|| type === 'block-end'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Shared assistant step-timing fold: both transcript projections (the live
|
||||
// window adapter and the trajectory history fold) derive AssistantTiming from
|
||||
// the same step/start -> first token delta -> assistant/message sequence, so
|
||||
// the derivation lives once here instead of drifting per projection.
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { AssistantTiming } from './conversation.ts'
|
||||
|
||||
/** Pre-finalize timing boundaries for one assistant step (start + first token). */
|
||||
export interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite map key for one assistant step.
|
||||
* @param turn - turn number from the event payload.
|
||||
* @param step - step number from the event payload.
|
||||
* @returns collision-free `turn`/`step` key (NUL separator).
|
||||
*/
|
||||
export function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a chunk carries visible model output (first-token boundary). Empty
|
||||
* deltas (heartbeats, empty tool-call frames) do not count as a first token.
|
||||
* @param chunk - the assistant/chunk payload.
|
||||
* @returns true when the chunk contains a non-empty text/reasoning/tool delta.
|
||||
*/
|
||||
export function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one event into the per-step timing index: step/start opens the entry,
|
||||
* the first non-empty token delta stamps first-token time once. Other event
|
||||
* types are no-ops.
|
||||
* @param steps - the mutable per-step index, keyed by {@link assistantStepKey}.
|
||||
* @param event - the raw window event.
|
||||
*/
|
||||
export function indexAssistantStepTiming(steps: Map<string, AssistantStepMetadata>, event: SessionEvent): void {
|
||||
if (event.type === 'step/start') {
|
||||
steps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = steps.get(key) ?? { stepStartTime: null, firstTokenTime: null }
|
||||
if (current.firstTokenTime === null) {
|
||||
steps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle one finalized assistant message's timing from its step entry; a step
|
||||
* whose start or first token fell outside the window yields null boundaries.
|
||||
* @param steps - the per-step index built by {@link indexAssistantStepTiming}.
|
||||
* @param turn - the assistant/message turn number.
|
||||
* @param step - the assistant/message step number.
|
||||
* @param completedTime - the assistant/message event timestamp (epoch ms).
|
||||
* @returns the node-ready timing record.
|
||||
*/
|
||||
export function settledAssistantTiming(
|
||||
steps: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
turn: number,
|
||||
step: number,
|
||||
completedTime: number,
|
||||
): AssistantTiming {
|
||||
return {
|
||||
...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }),
|
||||
completedTime,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Context provenance projection: the role and the human-facing producer name
|
||||
// of one logged non-user `user/message`, read from its durable `source` alone.
|
||||
// The client keeps no table of known plugin ids — a renamed or newly mounted
|
||||
// producer must never need a client release to stay identifiable, and a resumed
|
||||
// or foreign log must project the same way as a live one.
|
||||
|
||||
/**
|
||||
* Which model-facing role a logged non-user message plays.
|
||||
*
|
||||
* `recall` marks material lifted out of another session's log; `inject` marks
|
||||
* every other producer-supplied context. Mid-turn steering is the third role
|
||||
* the transcript distinguishes, but it has its own event and node kind
|
||||
* (`steering/message` / `SteeringMessageNode`) and never reaches here.
|
||||
*/
|
||||
export type ContextRole = 'inject' | 'recall'
|
||||
|
||||
/** Role and producer name presented for one logged non-user message. */
|
||||
export interface ContextProvenanceView {
|
||||
/** The role this context plays in the model-facing conversation. */
|
||||
role: ContextRole
|
||||
/**
|
||||
* Producer name for the row header, taken from the durable source: the
|
||||
* instruction paths, the referenced session titles, the plugin id, or the
|
||||
* bare source kind for a producer this UI version does not know. Null only
|
||||
* when the source carries no readable kind at all.
|
||||
*/
|
||||
label: string | null
|
||||
}
|
||||
|
||||
/** One durable source narrowed to the readable-record shape; null for anything else. */
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
/** A record field read as a non-empty string, or null. */
|
||||
function readString(record: Record<string, unknown>, key: string): string | null {
|
||||
const value = record[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */
|
||||
function collect(source: Record<string, unknown>, member: string, field: string): string[] {
|
||||
const list = source[member]
|
||||
if (!Array.isArray(list)) return []
|
||||
const seen: string[] = []
|
||||
for (const entry of list) {
|
||||
const record = asRecord(entry)
|
||||
const value = record === null ? null : readString(record, field)
|
||||
if (value !== null && !seen.includes(value)) seen.push(value)
|
||||
}
|
||||
return seen
|
||||
}
|
||||
|
||||
/** A collected name list rendered as one label; null when the list is empty. */
|
||||
function joined(names: string[]): string | null {
|
||||
return names.length > 0 ? names.join(', ') : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one durable message source onto its transcript role and producer name.
|
||||
*
|
||||
* The source arrives over the wire as opaque JSON (`MessageSource` is
|
||||
* merge-extensible, so no client-side union can be exhaustive), and a durable
|
||||
* log may predate or postdate this UI; every unreadable shape therefore
|
||||
* degrades to `inject` with whatever name the record still carries.
|
||||
* @param source - the logged `user/message` source, exactly as recorded.
|
||||
* @returns the role and producer name to present for this context.
|
||||
*/
|
||||
export function contextProvenance(source: unknown): ContextProvenanceView {
|
||||
const record = asRecord(source)
|
||||
const kind = record === null ? null : readString(record, 'kind')
|
||||
if (record === null || kind === null) return { role: 'inject', label: null }
|
||||
switch (kind) {
|
||||
// Cross-session snapshots are the one durable source that carries another
|
||||
// session's material; its references name the sessions they were read from.
|
||||
case 'session-reference':
|
||||
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
|
||||
// Workspace instructions name the files they were reconciled from, which
|
||||
// identifies the producer far better than the plugin id would.
|
||||
case 'workspace-instructions':
|
||||
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
|
||||
case 'plugin':
|
||||
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
|
||||
// Documented default arm of the merge-extensible source map: an unknown
|
||||
// producer still identifies itself by its own durable kind.
|
||||
default:
|
||||
return { role: 'inject', label: kind }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context forms this UI version renders with a dedicated presentation. The
|
||||
* durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an
|
||||
* unrecognized or absent value degrades to the opaque presentation rather than
|
||||
* dropping the row, so a log written by a newer or foreign producer still
|
||||
* renders.
|
||||
*/
|
||||
const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const
|
||||
|
||||
/** One durable context form this UI version knows how to present. */
|
||||
export type KnownContextForm = typeof KNOWN_FORMS[number]
|
||||
|
||||
/**
|
||||
* Read the producer-declared form off one durable message source.
|
||||
* @param source - the logged `user/message` source, exactly as recorded.
|
||||
* @returns the form when this UI version presents it, otherwise null (opaque).
|
||||
*/
|
||||
export function contextForm(source: unknown): KnownContextForm | null {
|
||||
const record = asRecord(source)
|
||||
const form = record === null ? null : readString(record, 'form')
|
||||
return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
|
||||
? form as KnownContextForm
|
||||
: null
|
||||
}
|
||||
@@ -4,13 +4,15 @@
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/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 {
|
||||
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
|
||||
export type { TodoItem }
|
||||
|
||||
/** Request configuration recorded for one provider call. */
|
||||
@@ -101,13 +103,14 @@ export interface AssistantMessageNode {
|
||||
interrupted?: true
|
||||
}
|
||||
|
||||
/** A steering message injected mid-turn. */
|
||||
/** A human message admitted from the next-step inbox while a turn was running. */
|
||||
export interface SteeringMessageNode {
|
||||
kind: 'steering'
|
||||
/** Stable message identity shared with its pre-admission inbox occurrence. */
|
||||
messageId: MessageId
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
turn: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
@@ -120,6 +123,10 @@ export interface ContextMessageNode {
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
/** Role and producer name projected from `source` ({@link contextProvenance}). */
|
||||
provenance: ContextProvenanceView
|
||||
/** Producer-declared information form ({@link contextForm}); null presents as opaque. */
|
||||
form: KnownContextForm | null
|
||||
}
|
||||
|
||||
/** Durable notice that a closed failed step is waiting for a model-request retry. */
|
||||
@@ -271,9 +278,15 @@ export interface RunningToolCall {
|
||||
}
|
||||
|
||||
|
||||
/** One independently addressable row from the transient queue snapshot. */
|
||||
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
|
||||
export interface QueuedMessage {
|
||||
readonly id: InboxItemId
|
||||
readonly id: MessageId
|
||||
/** Stable message identity used for transient-to-durable steering handoff. */
|
||||
readonly messageId: MessageId
|
||||
/** Agent-resolved placement; only queued rows accept queue mutations. */
|
||||
readonly placement: 'queued' | 'steering' | 'context'
|
||||
/** Complete content used to render pending steering before it becomes durable. */
|
||||
readonly content: readonly ContentBlock[]
|
||||
readonly preview: string
|
||||
/** Complete editable text; null when the message contains non-text blocks. */
|
||||
readonly text: string | null
|
||||
@@ -322,6 +335,10 @@ export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Exact in-window `turn/start` time and optional matching `turn/end` time. */
|
||||
turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
|
||||
/** In-window completed turn number -> its `turn/end` event seq. */
|
||||
turnEnds: ReadonlyMap<number, number>
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
/**
|
||||
@@ -332,9 +349,14 @@ export interface ConversationSnapshot {
|
||||
*/
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
pending: readonly PendingInteraction[]
|
||||
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
|
||||
/** Authoritative transient inbox snapshot, including queued and steering placements. */
|
||||
queue: readonly QueuedMessage[]
|
||||
running: boolean
|
||||
/**
|
||||
* Catalog-discovered continuation address. Its parent availability controls
|
||||
* human input; null means ordinary session transport.
|
||||
*/
|
||||
subagent: { address: SubagentAddress; parentAvailable: boolean } | null
|
||||
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
|
||||
composerPhase: ComposerPhase
|
||||
/** Set after host/session-removed; the UI grays out and disables input. */
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Convert a durable failure into copy that is safe to expose in the GUI.
|
||||
* @param failure - Structured failure preserved by the session event.
|
||||
* @param failure - Failure value preserved by the session event.
|
||||
* @returns Display-safe copy for client projections.
|
||||
*/
|
||||
export function displayFailureMessage(failure: { code?: string; message: string }): string {
|
||||
export function displayFailureMessage(failure: unknown): string {
|
||||
if (failure === null || typeof failure !== 'object') return String(failure)
|
||||
const record = failure as { code?: unknown; message?: unknown }
|
||||
// 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
|
||||
if (record.code === 'AUTH') return 'API key is invalid'
|
||||
return typeof record.message === 'string' ? record.message : JSON.stringify(failure)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,24 @@ import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from '../session-history/history-fold.ts'
|
||||
import { inspectRequests, type RequestView } from './request-inspection.ts'
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function isFirstTokenCandidate(entry: HistoryEntry): boolean {
|
||||
const event = entry.event
|
||||
if (event.type !== 'assistant/chunk') return false
|
||||
switch (event.data.chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return event.data.chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazily derived inspection data for one immutable session-history window. */
|
||||
export interface SessionHistoryInspection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
@@ -19,6 +37,47 @@ export interface SessionHistoryInspection {
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove completed-step token payloads that no inspection projection reads.
|
||||
* The first visible token preserves timing, usage chunks preserve accounting,
|
||||
* and unfinished steps retain every chunk for live or interrupted content.
|
||||
* @param entries - Contiguous raw history entries in sequence order.
|
||||
* @returns A projection-equivalent, usually much smaller entry ledger.
|
||||
*/
|
||||
export function compactHistoryInspectionEntries(
|
||||
entries: readonly HistoryEntry[],
|
||||
): readonly HistoryEntry[] {
|
||||
const completedSteps = new Set<string>()
|
||||
for (const { event } of entries) {
|
||||
if (event.type === 'assistant/message') {
|
||||
completedSteps.add(assistantStepKey(event.data.turn, event.data.step))
|
||||
}
|
||||
}
|
||||
|
||||
const firstTokenSteps = new Set<string>()
|
||||
const compacted: HistoryEntry[] = []
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const event = entry.event
|
||||
if (event.type !== 'assistant/chunk') {
|
||||
compacted.push(entry)
|
||||
continue
|
||||
}
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
if (!completedSteps.has(key) || event.data.chunk.type === 'usage') {
|
||||
compacted.push(entry)
|
||||
continue
|
||||
}
|
||||
if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) {
|
||||
firstTokenSteps.add(key)
|
||||
compacted.push(entry)
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? compacted : entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lazy inspection projection over an immutable history window.
|
||||
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
|
||||
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { PendingInteractionStatus } from './pending.ts'
|
||||
|
||||
/** Host list summary enriched with the latest mux-projected durable title. */
|
||||
export interface TitledSessionSummary extends SessionSummary {
|
||||
title?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
|
||||
/** One flattened session-list row with lineage depth and live pending interaction. */
|
||||
export interface SessionListEntry {
|
||||
sessionId: SessionId
|
||||
title?: string
|
||||
@@ -18,9 +22,15 @@ export interface SessionListEntry {
|
||||
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
|
||||
waitingApproval: boolean
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
/** User interaction currently blocking this session, derived from live mux frames. */
|
||||
pendingInteraction?: PendingInteractionStatus
|
||||
/** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
|
||||
completed: boolean
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
depth: number
|
||||
}
|
||||
@@ -30,10 +40,15 @@ export interface SessionListEntry {
|
||||
* follows the established input order; this projection never re-sorts a
|
||||
* hydrated list from mutable timestamps.
|
||||
* @param summaries - the host's session.list items.
|
||||
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
|
||||
* @param pendingInteractions - current manager-owned interaction status by session.
|
||||
* @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false).
|
||||
* @returns display rows in render order.
|
||||
*/
|
||||
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
|
||||
export function flattenLineage(
|
||||
summaries: readonly TitledSessionSummary[],
|
||||
pendingInteractions?: ReadonlyMap<SessionId, PendingInteractionStatus>,
|
||||
completed?: ReadonlySet<SessionId>,
|
||||
): SessionListEntry[] {
|
||||
const byId = new Map<SessionId, TitledSessionSummary>()
|
||||
for (const s of summaries) byId.set(s.sessionId, s)
|
||||
|
||||
@@ -57,7 +72,13 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[], waiti
|
||||
return
|
||||
}
|
||||
visited.add(s.sessionId)
|
||||
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
|
||||
const pendingInteraction = pendingInteractions?.get(s.sessionId)
|
||||
out.push({
|
||||
...s,
|
||||
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
|
||||
completed: completed?.has(s.sessionId) ?? false,
|
||||
depth,
|
||||
})
|
||||
const kids = children.get(s.sessionId)
|
||||
if (kids === undefined) return
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, WorkspaceId,
|
||||
SessionSummary, SubagentAddress, SubagentCatalog, 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.
|
||||
@@ -12,6 +12,7 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import type { PendingInteractionStatus } from './pending.ts'
|
||||
// Type-only merge edge: the title domain's client-namespace outlet declares
|
||||
// the 'title' projection key this manager projects into list rows (and any
|
||||
// useProjection('title') consumer reads). Zero value imports by construction.
|
||||
@@ -45,6 +46,22 @@ export interface SessionListSnapshot {
|
||||
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
|
||||
phase: SessionListPhase
|
||||
error: RpcError | null
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
|
||||
/** One parent-addressed durable catalog projected through the sessions snapshot. */
|
||||
export interface SubagentCatalogSnapshot extends SubagentCatalog {
|
||||
state: 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
interface CatalogInflight {
|
||||
readonly promise: Promise<void>
|
||||
readonly expandableRows: Set<SessionId>
|
||||
readonly activityRows: Map<SessionId, 'running' | 'inactive'>
|
||||
/** Removal-time invalidation replayed over the response this request predates. */
|
||||
parentAvailableOverride: false | undefined
|
||||
}
|
||||
|
||||
type SessionListMutation =
|
||||
@@ -54,23 +71,52 @@ type SessionListMutation =
|
||||
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
|
||||
| { kind: 'engaged'; sessionId: SessionId }
|
||||
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
/** Stable identity of a frame retained until an uninstantiated Session can consume it. */
|
||||
function bufferedRequestKey(envelope: RpcRequest<MuxFrame>): string | undefined {
|
||||
const frame = envelope.payload
|
||||
switch (frame.type) {
|
||||
case 'approval/requested': return `a:${frame.approvalId}`
|
||||
case 'question/requested': return `q:${envelope.rpcId}`
|
||||
case 'session/queue': return 'queue'
|
||||
/* v8 ignore next -- pendingBuffers contains only the three frame types above. */
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Match ui-question's binary plan-review routing at the wire boundary. */
|
||||
function questionInteractionStatus(
|
||||
questions: Extract<MuxFrame, { type: 'question/requested' }>['questions'],
|
||||
): PendingInteractionStatus {
|
||||
if (questions.length !== 1) return 'question'
|
||||
const question = questions[0] as typeof questions[number]
|
||||
const intent = question.intent
|
||||
if (intent?.kind !== 'plan-review' || question.detail === undefined) return 'question'
|
||||
if (question.multiSelect === true) return 'question'
|
||||
const options = question.options ?? []
|
||||
if (options.length > 2) return 'question'
|
||||
return options.some(option => option.label === intent.approve) ? 'plan-review' : 'question'
|
||||
}
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
export class SessionManager {
|
||||
private readonly sessions = new Map<SessionId, Session>()
|
||||
/** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit
|
||||
* history (cannot be backfilled on open), the one frame class that must not take the
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
/** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history
|
||||
* cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates
|
||||
* compact by identity. Instantiation replays and clears it, while removal drops it (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
|
||||
* replays of the same requested frame). Manager-owned rather than read off Session instances
|
||||
* because the sidebar must light up for sessions never instantiated. Cleared per connection
|
||||
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
|
||||
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
|
||||
/** Outstanding answerable interactions per session, keyed by their stable request identity.
|
||||
* Manager-owned rather than read off Session instances because the sidebar must light up for
|
||||
* sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
|
||||
* still-pending requests — and on session-removed. */
|
||||
private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>()
|
||||
/**
|
||||
* Sessions that finished running while not selected — the sidebar's green
|
||||
* "done" reminder (manager-owned, survives connection generations; cleared
|
||||
* on select and session-removed, re-armed by the next completion).
|
||||
*/
|
||||
private readonly completedNotifications = new Set<SessionId>()
|
||||
/** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */
|
||||
private readonly prevRunning = new Map<SessionId, boolean>()
|
||||
/** Per-session projection value stores, retained independently of instance arrival (the
|
||||
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
||||
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
||||
@@ -84,6 +130,13 @@ export class SessionManager {
|
||||
private listInflight: Promise<void> | null = null
|
||||
/** Mutations arriving after a list request starts are replayed over its response. */
|
||||
private listMutations: SessionListMutation[] | null = null
|
||||
private readonly addresses = new Map<SessionId, SubagentAddress>()
|
||||
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
|
||||
private readonly catalogInflight = new Map<SessionId, CatalogInflight>()
|
||||
/** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
|
||||
private readonly catalogStale = new Set<SessionId>()
|
||||
private readonly openCatalogs = new Set<SessionId>()
|
||||
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
|
||||
|
||||
private selected: SessionId | undefined
|
||||
|
||||
@@ -104,22 +157,53 @@ export class SessionManager {
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
restoredSelection?: SessionId,
|
||||
restoredAddress?: SubagentAddress,
|
||||
) {
|
||||
this.selected = restoredSelection
|
||||
if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress)
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
}
|
||||
|
||||
// ---- Selection ----
|
||||
|
||||
/**
|
||||
* Select a listed Session.
|
||||
* @param sessionId - listed Session id.
|
||||
* Select a listed Session or a retained catalog-addressed child.
|
||||
* @param sessionId - listed or catalog-addressed Session id.
|
||||
*/
|
||||
select(sessionId: SessionId): void {
|
||||
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
|
||||
const address = this.navigationAddress(sessionId)
|
||||
if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
|
||||
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
||||
}
|
||||
if (address !== undefined) this.addresses.set(sessionId, address)
|
||||
this.sessions.get(sessionId)?.configureSubagent(
|
||||
address,
|
||||
address === undefined
|
||||
? false
|
||||
: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
|
||||
)
|
||||
this.selected = sessionId
|
||||
// Looking at the session consumes its completion reminder (dot clears).
|
||||
this.completedNotifications.delete(sessionId)
|
||||
void this.refreshSubagents(sessionId)
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a healthy child through its durable direct-parent address.
|
||||
* @param address - catalog-derived parent and child ids.
|
||||
*/
|
||||
selectSubagent(address: SubagentAddress): void {
|
||||
const catalog = this.catalogs.get(address.parentSessionId)
|
||||
const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId)
|
||||
if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) {
|
||||
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
|
||||
}
|
||||
this.addresses.set(address.childSessionId, address)
|
||||
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
|
||||
this.selected = address.childSessionId
|
||||
this.completedNotifications.delete(address.childSessionId)
|
||||
void this.refreshSubagents(address.childSessionId)
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
@@ -129,6 +213,32 @@ export class SessionManager {
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the durable catalog address retained for one child.
|
||||
* @param sessionId - possible addressed child id.
|
||||
* @returns The direct-parent address, when navigation discovered one.
|
||||
*/
|
||||
subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
|
||||
return this.addresses.get(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an address for breadcrumb navigation without retaining transport authority.
|
||||
* @param sessionId - possible child id in an already-loaded catalog.
|
||||
* @returns A retained or catalog-derived direct-parent address.
|
||||
*/
|
||||
navigationAddress(sessionId: SessionId): SubagentAddress | undefined {
|
||||
const retained = this.addresses.get(sessionId)
|
||||
if (retained !== undefined) return retained
|
||||
for (const [parentSessionId, catalog] of this.catalogs) {
|
||||
const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === sessionId)
|
||||
if (child?.kind === 'child') {
|
||||
return { parentSessionId, childSessionId: sessionId, mode: child.mode }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
/**
|
||||
@@ -168,13 +278,23 @@ export class SessionManager {
|
||||
if (summary !== undefined) {
|
||||
session.handleBlank(summary.blank)
|
||||
session.handleRunning(summary.running)
|
||||
} else {
|
||||
const address = this.addresses.get(sessionId)
|
||||
const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
|
||||
.find(entry => entry.kind === 'child' && entry.id === sessionId)
|
||||
if (child?.kind === 'child') session.handleRunning(child.activity === 'running')
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private createSession(sessionId: SessionId): Session {
|
||||
const address = this.addresses.get(sessionId)
|
||||
return new Session(sessionId, this.api, {
|
||||
...(address === undefined ? {} : {
|
||||
address,
|
||||
parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
|
||||
}),
|
||||
// The sender's local first-send flip mirrors into the list row so the
|
||||
// session surfaces (lists filter on blank) before any host frame lands.
|
||||
onEngaged: (engaged) => {
|
||||
@@ -197,6 +317,99 @@ export class SessionManager {
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh one direct-child catalog, reusing its in-flight request.
|
||||
* @param parentSessionId - catalog owner.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||||
const existing = this.catalogInflight.get(parentSessionId)
|
||||
if (existing !== undefined) return existing.promise
|
||||
const previous = this.catalogs.get(parentSessionId)
|
||||
const expandableRows = new Set<SessionId>()
|
||||
const activityRows = new Map<SessionId, 'running' | 'inactive'>()
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: previous?.entries ?? [],
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
state: 'loading',
|
||||
error: null,
|
||||
})
|
||||
this.notifier.markDirty()
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.subagents.list({ parentSessionId })
|
||||
if (result.ok) {
|
||||
const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
||||
?? result.value.parentAvailable
|
||||
this.catalogs.set(parentSessionId, {
|
||||
...result.value,
|
||||
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
|
||||
parentAvailable,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
})
|
||||
for (const [childId, address] of this.addresses) {
|
||||
if (address.parentSessionId !== parentSessionId) continue
|
||||
this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable)
|
||||
}
|
||||
} else {
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: this.withCatalogMutations(
|
||||
previous?.entries ?? [], expandableRows, activityRows,
|
||||
),
|
||||
parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
||||
?? previous?.parentAvailable ?? false,
|
||||
state: 'error',
|
||||
error: result.error,
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const folded = transportError<never>(error)
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: this.withCatalogMutations(
|
||||
previous?.entries ?? [], expandableRows, activityRows,
|
||||
),
|
||||
parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
|
||||
?? previous?.parentAvailable ?? false,
|
||||
state: 'error',
|
||||
error: folded.ok ? null : folded.error,
|
||||
})
|
||||
} finally {
|
||||
this.catalogInflight.delete(parentSessionId)
|
||||
// Re-arm the trailing pull before the dirty notify: the response the
|
||||
// caller observed predates the stale-marking change, so the follow-up
|
||||
// refresh is the only carrier of that change.
|
||||
if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
})()
|
||||
this.catalogInflight.set(parentSessionId, {
|
||||
promise: operation,
|
||||
expandableRows,
|
||||
activityRows,
|
||||
parentAvailableOverride: undefined,
|
||||
})
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark whether a catalog menu is consuming live membership updates.
|
||||
* @param parentSessionId - catalog owner.
|
||||
* @param open - current menu state.
|
||||
*/
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
|
||||
if (open) {
|
||||
this.openCatalogs.add(parentSessionId)
|
||||
void this.refreshSubagents(parentSessionId)
|
||||
} else {
|
||||
this.openCatalogs.delete(parentSessionId)
|
||||
const timer = this.catalogDebounce.get(parentSessionId)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
this.catalogDebounce.delete(parentSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
@@ -212,13 +425,28 @@ export class SessionManager {
|
||||
try {
|
||||
const { result } = await this.api.sessions.list({})
|
||||
if (result.ok) {
|
||||
let summaries = this.listPhase === 'pending'
|
||||
const baseline = this.listPhase === 'pending'
|
||||
? result.value.items
|
||||
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
|
||||
for (const mutation of mutations) summaries = applyMutation(summaries, mutation)
|
||||
// Seed first observations from the pull-time baseline BEFORE replaying
|
||||
// in-flight mutations, then reconcile the reminders after EVERY
|
||||
// replayed mutation: an edge that happens entirely between mutations
|
||||
// (baseline idle → running → idle) must still arm, which a single
|
||||
// sync on the folded result would collapse away.
|
||||
for (const s of baseline) {
|
||||
if (!this.prevRunning.has(s.sessionId)) this.prevRunning.set(s.sessionId, s.running)
|
||||
}
|
||||
let summaries = baseline
|
||||
for (const mutation of mutations) {
|
||||
summaries = applyMutation(summaries, mutation)
|
||||
this.summaries = summaries
|
||||
this.syncCompletedNotifications()
|
||||
}
|
||||
this.summaries = summaries
|
||||
this.listState = 'idle'
|
||||
this.listPhase = 'ready'
|
||||
// Covers the empty-mutations pull (a plain baseline carries no edge).
|
||||
this.syncCompletedNotifications()
|
||||
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
for (const s of this.summaries) {
|
||||
const session = this.sessions.get(s.sessionId)
|
||||
@@ -364,6 +592,8 @@ export class SessionManager {
|
||||
private recordMutation(mutation: SessionListMutation): void {
|
||||
this.listMutations?.push(mutation)
|
||||
this.summaries = applyMutation(this.summaries, mutation)
|
||||
// Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames.
|
||||
this.syncCompletedNotifications()
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -387,6 +617,26 @@ export class SessionManager {
|
||||
return this.listSnapshotCache
|
||||
}
|
||||
|
||||
/** Add or refresh one stable pending-interaction identity. */
|
||||
private trackPending(sessionId: SessionId, key: string, status: PendingInteractionStatus): void {
|
||||
let interactions = this.pendingInteractions.get(sessionId)
|
||||
if (interactions === undefined) {
|
||||
interactions = new Map()
|
||||
this.pendingInteractions.set(sessionId, interactions)
|
||||
}
|
||||
if (interactions.get(key) === status) return
|
||||
interactions.set(key, status)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Settle one pending-interaction identity without disturbing sibling waits. */
|
||||
private resolvePending(sessionId: SessionId, key: string): void {
|
||||
const interactions = this.pendingInteractions.get(sessionId)
|
||||
if (interactions === undefined || !interactions.delete(key)) return
|
||||
if (interactions.size === 0) this.pendingInteractions.delete(sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
// ---- ConnectionController sinks (wired by boot) ----
|
||||
|
||||
/**
|
||||
@@ -412,11 +662,10 @@ export class SessionManager {
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
this.notifier.markDirty()
|
||||
// New mux-generation baseline: buffered session/queue frames belong to
|
||||
// the previous generation and the host is about to resend the live
|
||||
// snapshot — drop them, or every reconnect appends a duplicate batch
|
||||
// (and enough reconnects push real approval/question frames past the
|
||||
// cap). Same re-baseline signal Session uses for its own mirror.
|
||||
// New mux-generation baseline: discard the previous queue snapshot.
|
||||
// The host omits session/queue when the live queue is empty, so retaining
|
||||
// it could replay stale work when the Session is instantiated later.
|
||||
// This is the same re-baseline signal Session uses for its own mirror.
|
||||
const buffered = this.pendingBuffers.get(frame.sessionId)
|
||||
if (buffered !== undefined) {
|
||||
const kept = buffered.filter(item => item.payload.type !== 'session/queue')
|
||||
@@ -426,43 +675,54 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
|
||||
// every session, instantiated or not; approvalId keys make replays idempotent.
|
||||
// List-level pending-interaction status (the sidebar amber dot): tracked
|
||||
// for every session, instantiated or not; stable keys make replays idempotent.
|
||||
if (frame.type === 'approval/requested') {
|
||||
let ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
|
||||
if (!ids.has(frame.approvalId)) {
|
||||
ids.add(frame.approvalId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
this.trackPending(frame.sessionId, `a:${frame.approvalId}`, 'approval')
|
||||
} else if (frame.type === 'approval/resolved') {
|
||||
const ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids !== undefined && ids.delete(frame.approvalId)) {
|
||||
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
this.resolvePending(frame.sessionId, `a:${frame.approvalId}`)
|
||||
} else if (frame.type === 'question/requested') {
|
||||
this.trackPending(
|
||||
frame.sessionId,
|
||||
`q:${envelope.rpcId}`,
|
||||
questionInteractionStatus(frame.questions),
|
||||
)
|
||||
} else if (frame.type === 'question/resolved') {
|
||||
this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`)
|
||||
}
|
||||
const session = this.sessions.get(frame.sessionId)
|
||||
if (session === undefined) {
|
||||
// Approval/question/queue frames never hit history: buffer for replay on
|
||||
// instantiation; everything else drops (not instantiated — history fully
|
||||
// backfills on open).
|
||||
// Answerable requests never hit history: retain each live identity until
|
||||
// instantiation, compacting replay duplicates and resolutions so list
|
||||
// status cannot outlive the PendingWait the user would need to answer.
|
||||
// Queue is a latest-value snapshot; everything else drops because open
|
||||
// backfills it from history.
|
||||
switch (frame.type) {
|
||||
case 'approval/requested':
|
||||
case 'approval/resolved':
|
||||
case 'question/requested':
|
||||
case 'question/resolved':
|
||||
case 'session/queue': {
|
||||
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
|
||||
const prior = frame.type === 'session/queue'
|
||||
? buffer.findIndex(item => item.payload.type === 'session/queue')
|
||||
: -1
|
||||
if (prior !== -1) buffer.splice(prior, 1)
|
||||
buffer.push(envelope)
|
||||
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
|
||||
const key = frame.type === 'approval/requested'
|
||||
? `a:${frame.approvalId}`
|
||||
: frame.type === 'question/requested' ? `q:${envelope.rpcId}` : 'queue'
|
||||
const prior = buffer.findIndex(item => bufferedRequestKey(item) === key)
|
||||
if (prior === -1) buffer.push(envelope)
|
||||
else buffer[prior] = envelope
|
||||
this.pendingBuffers.set(frame.sessionId, buffer)
|
||||
return
|
||||
}
|
||||
case 'approval/resolved':
|
||||
case 'question/resolved': {
|
||||
const buffer = this.pendingBuffers.get(frame.sessionId)
|
||||
if (buffer === undefined) return
|
||||
const key = frame.type === 'approval/resolved'
|
||||
? `a:${frame.approvalId}`
|
||||
: `q:${frame.questionRpcId}`
|
||||
const prior = buffer.findIndex(item => bufferedRequestKey(item) === key)
|
||||
if (prior !== -1) buffer.splice(prior, 1)
|
||||
if (buffer.length === 0) this.pendingBuffers.delete(frame.sessionId)
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -481,22 +741,65 @@ export class SessionManager {
|
||||
this.mergeSummary({
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
...(frame.origin !== undefined ? { origin: frame.origin } : {}),
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
||||
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
|
||||
this.markCatalogParentExpandable(frame.parentSessionId)
|
||||
}
|
||||
if (frame.parentSessionId !== undefined
|
||||
&& (this.selected === frame.parentSessionId || this.openCatalogs.has(frame.parentSessionId))) {
|
||||
this.scheduleCatalogRefresh(frame.parentSessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
|
||||
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
|
||||
this.recordMutation(durableSubagent
|
||||
? { kind: 'status', sessionId: frame.sessionId, running: false }
|
||||
: { kind: 'remove', sessionId: frame.sessionId })
|
||||
this.updateCatalogActivity(frame.sessionId, false)
|
||||
if (durableSubagent) {
|
||||
// An Activation detaching is not durable child deletion:
|
||||
// keep its lineage and conversation while returning it to idle.
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(false)
|
||||
} else {
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved()
|
||||
}
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
|
||||
// A pull already in flight was requested before this removal and can
|
||||
// carry the pre-removal parentAvailable:true, which would resurrect
|
||||
// the writable editor this invalidation just closed. Replay false over
|
||||
// that response and queue one trailing refresh so the post-removal
|
||||
// host truth converges.
|
||||
const inflightCatalog = this.catalogInflight.get(frame.sessionId)
|
||||
if (inflightCatalog !== undefined) {
|
||||
inflightCatalog.parentAvailableOverride = false
|
||||
this.catalogStale.add(frame.sessionId)
|
||||
}
|
||||
// The removed session can no longer be the delivery owner of its
|
||||
// catalog: invalidate availability immediately. Removal schedules no
|
||||
// catalog refresh, and without this an addressed child keeps a
|
||||
// writable editor against a dead continuation owner until an
|
||||
// unrelated refresh (or forever, for a closed menu).
|
||||
const ownedCatalog = this.catalogs.get(frame.sessionId)
|
||||
if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
|
||||
this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false })
|
||||
}
|
||||
for (const [childId, address] of this.addresses) {
|
||||
if (address.parentSessionId !== frame.sessionId) continue
|
||||
this.sessions.get(childId)?.handleSubagentParentAvailable(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'host/session-status': {
|
||||
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
|
||||
this.updateCatalogActivity(frame.sessionId, frame.running)
|
||||
return
|
||||
}
|
||||
case 'host/agent-error': {
|
||||
@@ -512,20 +815,19 @@ export class SessionManager {
|
||||
* The moment a connection generation dies (before any next-generation frame
|
||||
* can arrive — onConnected waits for the readiness handshake while replayed
|
||||
* frames flow from stream open, so clearing there would race the replay):
|
||||
* drop generation-scoped live state. Approvals resolved while disconnected
|
||||
* send no frame, so the stale bits and the buffered answerable frames must
|
||||
* not survive into the next generation — the mux-open replay re-adds every
|
||||
* still-pending question with its live rpcId.
|
||||
*/
|
||||
* drop generation-scoped live state. Interactions resolved while disconnected
|
||||
* send no frame, so stale statuses and buffered answerable frames must not
|
||||
* survive into the next generation — mux-open replay re-adds every still-pending
|
||||
* request with its live rpcId.
|
||||
*/
|
||||
handleDisconnected(): void {
|
||||
if (this.waitingApprovals.size > 0) {
|
||||
this.waitingApprovals.clear()
|
||||
if (this.pendingInteractions.size > 0) {
|
||||
this.pendingInteractions.clear()
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
|
||||
const kept = buffer.filter(item =>
|
||||
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
|
||||
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
|
||||
item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested')
|
||||
if (kept.length === buffer.length) continue
|
||||
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
|
||||
else this.pendingBuffers.set(sessionId, kept)
|
||||
@@ -535,27 +837,154 @@ export class SessionManager {
|
||||
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
|
||||
handleConnected(): void {
|
||||
void this.refreshList()
|
||||
const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
|
||||
if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)
|
||||
if (this.selected !== undefined) void this.refreshSubagents(this.selected)
|
||||
for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId)
|
||||
for (const session of this.sessions.values()) void session.resync()
|
||||
}
|
||||
|
||||
/** Debounce membership refetches while one parent catalog is selected or open. */
|
||||
private scheduleCatalogRefresh(parentSessionId: SessionId): void {
|
||||
if (this.catalogDebounce.has(parentSessionId)) return
|
||||
const timer = setTimeout(() => {
|
||||
this.catalogDebounce.delete(parentSessionId)
|
||||
// The in-flight response predates the membership frame that scheduled
|
||||
// this callback. Queue one post-settlement pull instead of treating an
|
||||
// ordinary overlapping read as evidence that catalog membership changed.
|
||||
if (this.catalogInflight.has(parentSessionId)) {
|
||||
this.catalogStale.add(parentSessionId)
|
||||
return
|
||||
}
|
||||
void this.refreshSubagents(parentSessionId)
|
||||
}, 50)
|
||||
this.catalogDebounce.set(parentSessionId, timer)
|
||||
}
|
||||
|
||||
/** Apply one Agent-driver transition to loaded and in-flight catalogs. */
|
||||
private updateCatalogActivity(childSessionId: SessionId, running: boolean): void {
|
||||
const activity = running ? 'running' as const : 'inactive' as const
|
||||
for (const inflight of this.catalogInflight.values()) {
|
||||
inflight.activityRows.set(childSessionId, activity)
|
||||
}
|
||||
let changed = false
|
||||
for (const [parentSessionId, catalog] of this.catalogs) {
|
||||
if (!catalog.entries.some(entry =>
|
||||
entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue
|
||||
const entries = catalog.entries.map((entry) => {
|
||||
if (entry.kind !== 'child' || entry.id !== childSessionId) return entry
|
||||
return { ...entry, activity }
|
||||
})
|
||||
changed = true
|
||||
this.catalogs.set(parentSessionId, { ...catalog, entries })
|
||||
}
|
||||
if (changed) this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Preserve and project a positive expandability hint after one direct subagent publishes. */
|
||||
private markCatalogParentExpandable(parentSessionId: SessionId): void {
|
||||
this.applyCatalogParentExpandable(parentSessionId)
|
||||
for (const inflight of this.catalogInflight.values()) inflight.expandableRows.add(parentSessionId)
|
||||
}
|
||||
|
||||
/** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
|
||||
private applyCatalogParentExpandable(parentSessionId: SessionId): void {
|
||||
let changed = false
|
||||
for (const [catalogParentId, catalog] of this.catalogs) {
|
||||
if (!catalog.entries.some(entry =>
|
||||
entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren)) continue
|
||||
const entries = catalog.entries.map((entry) => {
|
||||
if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren) return entry
|
||||
return { ...entry, hasChildren: true }
|
||||
})
|
||||
changed = true
|
||||
this.catalogs.set(catalogParentId, { ...catalog, entries })
|
||||
}
|
||||
if (changed) this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Fold request-local row mutations into one catalog result before publication. */
|
||||
private withCatalogMutations(
|
||||
entries: SubagentCatalog['entries'],
|
||||
expandableRows: ReadonlySet<SessionId>,
|
||||
activityRows: ReadonlyMap<SessionId, 'running' | 'inactive'>,
|
||||
): SubagentCatalog['entries'] {
|
||||
return entries.map((entry) => {
|
||||
if (entry.kind !== 'child') return entry
|
||||
const activity = activityRows.get(entry.id)
|
||||
if (!expandableRows.has(entry.id) && activity === undefined) return entry
|
||||
return {
|
||||
...entry,
|
||||
...expandableRows.has(entry.id) ? { hasChildren: true } : {},
|
||||
...activity === undefined ? {} : { activity },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile completion reminders against the latest summaries, eagerly after
|
||||
* every mutation and pull (a snapshot-build-time pass would collapse
|
||||
* consecutive status frames into one observation). A running→idle edge of a
|
||||
* non-selected session arms its reminder; running disarms it; removal drops
|
||||
* it. First observation only records the running bit — sessions already
|
||||
* idle at load get no reminder.
|
||||
*/
|
||||
private syncCompletedNotifications(): void {
|
||||
const seen = new Set<SessionId>()
|
||||
for (const s of this.summaries) {
|
||||
seen.add(s.sessionId)
|
||||
const prev = this.prevRunning.get(s.sessionId)
|
||||
if (prev === undefined) {
|
||||
this.prevRunning.set(s.sessionId, s.running)
|
||||
continue
|
||||
}
|
||||
if (prev && !s.running) {
|
||||
if (s.sessionId !== this.selected) this.completedNotifications.add(s.sessionId)
|
||||
} else if (s.running) {
|
||||
this.completedNotifications.delete(s.sessionId)
|
||||
}
|
||||
this.prevRunning.set(s.sessionId, s.running)
|
||||
}
|
||||
for (const id of this.prevRunning.keys()) {
|
||||
if (!seen.has(id)) this.prevRunning.delete(id)
|
||||
}
|
||||
for (const id of this.completedNotifications) {
|
||||
if (!seen.has(id)) this.completedNotifications.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
// value; the bespoke session/title frame is retired).
|
||||
const title = this.projectionStores.get(summary.sessionId)?.get('title')
|
||||
return typeof title === 'string' && title !== ''
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
const projectionStore = this.projectionStores.get(summary.sessionId)
|
||||
const title = projectionStore?.get('title')
|
||||
const projectionValues = projectionStore?.values()
|
||||
return {
|
||||
...summary,
|
||||
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
}
|
||||
})
|
||||
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
|
||||
const pendingInteractions = new Map<SessionId, PendingInteractionStatus>()
|
||||
for (const [sessionId, interactions] of this.pendingInteractions) {
|
||||
const statuses = [...interactions.values()]
|
||||
// The composer selects the first question ahead of approval. Mirror that
|
||||
// answer order so the sidebar names the interaction the user can act on.
|
||||
const status = statuses.find(candidate => candidate !== 'approval') ?? statuses[0]
|
||||
if (status !== undefined) pendingInteractions.set(sessionId, status)
|
||||
}
|
||||
const fresh = flattenLineage(merged, pendingInteractions, this.completedNotifications)
|
||||
const items = fresh.map((entry) => {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.waitingApproval === entry.waitingApproval
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.pendingInteraction === entry.pendingInteraction
|
||||
&& prev.projectionValues === entry.projectionValues
|
||||
&& prev.completed === entry.completed
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
return entry
|
||||
@@ -566,7 +995,8 @@ export class SessionManager {
|
||||
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
|
||||
if (!sameOrder) this.itemsCache = items
|
||||
const selected = this.selected
|
||||
const current = selected !== undefined && items.some(item => item.sessionId === selected)
|
||||
const current = selected !== undefined
|
||||
&& (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
|
||||
? selected
|
||||
: undefined
|
||||
return {
|
||||
@@ -575,6 +1005,8 @@ export class SessionManager {
|
||||
state: this.listState,
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
subagentsByParent: Object.fromEntries(this.catalogs),
|
||||
currentAddress: current === undefined ? undefined : this.addresses.get(current),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -593,9 +1025,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
|
||||
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
|
||||
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
|
||||
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
||||
...(existing.origin === undefined && mutation.summary.origin !== undefined
|
||||
? { origin: mutation.summary.origin } : {}),
|
||||
}
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
|
||||
&& filled.blank === existing.blank) return [...summaries]
|
||||
&& filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries]
|
||||
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
|
||||
}
|
||||
case 'remove':
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Notifier: subscription + microtask-batched notification primitive shared by Session and
|
||||
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
|
||||
// Notifier: subscription + batched notification primitive shared by Session and
|
||||
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush, while
|
||||
// N markFrameDirty calls collapse into one animation-frame flush;
|
||||
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
|
||||
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
|
||||
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
|
||||
@@ -9,12 +10,13 @@
|
||||
// swallow the notification — push subscribers (object-layer watchers) would
|
||||
// otherwise starve whenever any reader pulls first.
|
||||
|
||||
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
|
||||
/** Subscription + batched notification primitive (shared by Session and SessionManager). */
|
||||
export class Notifier {
|
||||
private listeners = new Set<() => void>()
|
||||
private dirty = false
|
||||
private notifyPending = false
|
||||
private scheduled = false
|
||||
private scheduled: 'none' | 'microtask' | 'frame' = 'none'
|
||||
private scheduleGeneration = 0
|
||||
|
||||
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
|
||||
constructor(private readonly rebuild: () => void) {}
|
||||
@@ -35,19 +37,16 @@ export class Notifier {
|
||||
markDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled) return
|
||||
this.scheduled = true
|
||||
queueMicrotask(() => {
|
||||
this.scheduled = false
|
||||
if (!this.notifyPending) return
|
||||
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
|
||||
this.notifyPending = false
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
for (const listener of this.listeners) listener()
|
||||
})
|
||||
if (this.scheduled === 'microtask') return
|
||||
this.schedule('microtask')
|
||||
}
|
||||
|
||||
/** Stream-change entry: mark dirty and publish the cumulative state at most once per frame. */
|
||||
markFrameDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled !== 'none') return
|
||||
this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +56,8 @@ export class Notifier {
|
||||
notifyNow(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
|
||||
this.notifyPending = false
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
for (const listener of this.listeners) listener()
|
||||
this.invalidateSchedule()
|
||||
this.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,4 +69,35 @@ export class Notifier {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
private schedule(kind: 'microtask' | 'frame'): void {
|
||||
const generation = ++this.scheduleGeneration
|
||||
this.scheduled = kind
|
||||
const publish = () => {
|
||||
if (generation !== this.scheduleGeneration) return
|
||||
this.scheduled = 'none'
|
||||
this.flush()
|
||||
}
|
||||
if (kind === 'frame') {
|
||||
globalThis.requestAnimationFrame(publish)
|
||||
} else {
|
||||
queueMicrotask(publish)
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateSchedule(): void {
|
||||
this.scheduleGeneration++
|
||||
this.scheduled = 'none'
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (!this.notifyPending) return
|
||||
if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
|
||||
this.notifyPending = false
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,19 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
|
||||
import { toAssistantBlock } from './conversation.ts'
|
||||
|
||||
/**
|
||||
* Whether a stream chunk changes the partial assistant projection shown by the UI.
|
||||
* @param type - Stream chunk discriminant.
|
||||
* @returns Whether publishing the accumulated partial can change the visible snapshot.
|
||||
*/
|
||||
export function isVisibleAssistantChunk(type: string): boolean {
|
||||
return type === 'block-start'
|
||||
|| type === 'text-delta'
|
||||
|| type === 'reasoning-delta'
|
||||
|| type === 'tool-call-delta'
|
||||
|| type === 'block-end'
|
||||
}
|
||||
|
||||
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
|
||||
export class PartialAccumulator {
|
||||
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface PendingPayloads {
|
||||
/** Pending-interaction discriminant (the keys of PendingPayloads). */
|
||||
export type PendingKind = keyof PendingPayloads
|
||||
|
||||
/** Session-list summary of the user action currently blocking progress. */
|
||||
export type PendingInteractionStatus = 'approval' | 'plan-review' | 'question'
|
||||
|
||||
/** Kind-discriminated union of concrete waits: narrowing on `kind` types `payload`. */
|
||||
export type PendingInteraction = { [K in PendingKind]: PendingWait<K> }[PendingKind]
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ interface Channel {
|
||||
export class ProjectionValueStore {
|
||||
private readonly rows = new Map<string, Row>()
|
||||
private readonly channels = new Map<string, Channel>()
|
||||
private valuesCache: Readonly<Partial<SessionProjectionMap>> | undefined
|
||||
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
|
||||
private readonly anyNotifier = new Notifier(() => {})
|
||||
|
||||
@@ -98,6 +99,19 @@ export class ProjectionValueStore {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every current projection value as one reference-stable snapshot.
|
||||
* @returns The same frozen value map until a row changes.
|
||||
*/
|
||||
values(): Readonly<Partial<SessionProjectionMap>> {
|
||||
if (this.valuesCache === undefined) {
|
||||
this.valuesCache = Object.freeze(Object.fromEntries(
|
||||
[...this.rows].map(([key, row]) => [key, row.value]),
|
||||
))
|
||||
}
|
||||
return this.valuesCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any-key changes (microtask-batched) — the manager's list
|
||||
* rebuild channel.
|
||||
@@ -160,6 +174,7 @@ export class ProjectionValueStore {
|
||||
}
|
||||
|
||||
private changed(key: string): void {
|
||||
this.valuesCache = undefined
|
||||
this.channels.get(key)?.notifier.markDirty()
|
||||
this.anyNotifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -94,7 +94,8 @@ export interface RequestInspectionSnapshot {
|
||||
/**
|
||||
* Derive the request-centric read model from one immutable history window.
|
||||
* Compaction participates as a request purpose rather than a parallel
|
||||
* top-level collection.
|
||||
* top-level collection. A leading resume/change header exposes its prompt but
|
||||
* cannot project a change until the preceding header enters the window.
|
||||
* @param entries - Contiguous raw session history.
|
||||
* @returns Requests and call-time schemas derived from that history.
|
||||
*/
|
||||
@@ -218,6 +219,7 @@ function promptChange(
|
||||
prompt: ConversationPromptSnapshot,
|
||||
event: SessionEvent<'request/header'>,
|
||||
): RequestPromptChange | undefined {
|
||||
if (previous === undefined && event.data.reason !== 'initial') return
|
||||
const systemChanged = previous !== undefined && previous.system !== prompt.system
|
||||
const toolsChanged = previous !== undefined
|
||||
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
|
||||
@@ -240,6 +242,7 @@ function promptChange(
|
||||
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
|
||||
const requests: RequestView[] = []
|
||||
const ordinaryByStep = new Map<string, number>()
|
||||
const lastStepByTurn = new Map<number, string>()
|
||||
let activeStep: string | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let activeCompaction: number | undefined
|
||||
@@ -266,6 +269,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
const { turn, step } = sourceEvent.data
|
||||
const key = requestKey(turn, step)
|
||||
ordinaryByStep.set(key, requests.length)
|
||||
lastStepByTurn.set(turn, key)
|
||||
requests.push({
|
||||
purpose: 'assistant',
|
||||
startSeq: sourceEvent.seq,
|
||||
@@ -358,12 +362,15 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
||||
})
|
||||
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),
|
||||
})
|
||||
if (sourceEvent.type === 'turn/end') {
|
||||
const lastStep = lastStepByTurn.get(sourceEvent.data.turn)
|
||||
if (sourceEvent.data.reason.kind === 'error') {
|
||||
updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), {
|
||||
status: 'error',
|
||||
error: displayFailureMessage(sourceEvent.data.reason.error),
|
||||
})
|
||||
}
|
||||
lastStepByTurn.delete(sourceEvent.data.turn)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||
* id), stable SessionBinding cache, ancestry walk.
|
||||
* id), stable SessionBinding cache, breadcrumb-route projection.
|
||||
*
|
||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||
* resolution (pure — resolution has no side effects and is render-safe);
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, 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.
|
||||
@@ -25,13 +25,15 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
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, SessionSearchResultItem } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
|
||||
import type { PendingInteractionStatus } from './pending.ts'
|
||||
import { SessionProvideChannel } from './provide.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -44,9 +46,13 @@ export interface SessionSummary {
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
running: boolean
|
||||
/** An approval question is pending on this session (sidebar amber-dot state). */
|
||||
waitingApproval: boolean
|
||||
/** User interaction currently blocking this session (sidebar amber-dot state). */
|
||||
pendingInteraction?: PendingInteractionStatus
|
||||
/** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
|
||||
completed?: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
@@ -55,6 +61,8 @@ export interface SessionSummary {
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
/** Current host-computed projection values retained by the object layer. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,11 +71,23 @@ export interface SessionSummary {
|
||||
* sidebar highlighting and SessionProvider share one fact source).
|
||||
*/
|
||||
export interface SessionListState {
|
||||
/** Host-list order; addressed breadcrumb-only rows are excluded. */
|
||||
ids: SessionId[]
|
||||
/** Host rows plus the current addressed subagent route used by navigation. */
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
current: SessionId | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
/** Current session's catalog-derived address, absent on ordinary navigation. */
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
|
||||
/** Persisted navigation cell: address survives refresh for correct history routing. */
|
||||
interface SessionSelection {
|
||||
sessionId?: SessionId
|
||||
subagentAddress?: SubagentAddress
|
||||
}
|
||||
|
||||
/** Structured session-create failure. */
|
||||
@@ -192,7 +212,7 @@ export interface SessionProvideDescriptor {
|
||||
resolve(binding: SessionBinding): SessionProvideContribution
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
|
||||
export class SessionsService implements ISessions {
|
||||
/**
|
||||
* The wire schema's own result bound, re-exposed for presentation plugins as
|
||||
@@ -221,7 +241,7 @@ export class SessionsService implements ISessions {
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
private readonly selection: SnapshotStore<SessionSelection>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
|
||||
@@ -244,12 +264,14 @@ export class SessionsService implements ISessions {
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
this.selection = createSnapshotStore<SessionSelection>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
const restored = this.selection.getSnapshot()
|
||||
this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
@@ -295,14 +317,48 @@ export class SessionsService implements ISessions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere.
|
||||
* @param id - session id (must exist in the list store).
|
||||
* Select a listed or retained catalog-addressed session as current.
|
||||
* @param id - listed or addressed session id.
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
this.manager.select(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a healthy catalog child through its direct-parent address.
|
||||
* @param address - catalog-derived parent and child ids.
|
||||
*/
|
||||
openSubagent(address: SubagentAddress): void {
|
||||
this.manager.selectSubagent(address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
|
||||
* @param id - possible addressed child id.
|
||||
* @returns The retained address, when present.
|
||||
*/
|
||||
subagentAddress(id: SessionId): SubagentAddress | undefined {
|
||||
return this.manager.subagentAddress(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform the runtime whether a catalog menu is consuming membership updates.
|
||||
* @param parentSessionId - selected parent.
|
||||
* @param open - menu state.
|
||||
*/
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
|
||||
this.manager.setSubagentCatalogOpen(parentSessionId, open)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
* @param parentSessionId - catalog owner.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||||
return this.manager.refreshSubagents(parentSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
@@ -509,33 +565,15 @@ export class SessionsService implements ISessions {
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.session.open()
|
||||
void this.manager.refreshSubagents(current)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb feed: walk parentId links inside the list store.
|
||||
* @param id - session id.
|
||||
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
|
||||
*/
|
||||
ancestry(id: SessionId): SessionSummary[] {
|
||||
const { byId } = this.list.getSnapshot()
|
||||
const chain: SessionSummary[] = []
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
const summary: SessionSummary | undefined = byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||
* prune share one predicate (decision 12): listed on the host — a scope is
|
||||
* born when its session enters the client's view (list mirror row from the
|
||||
* baseline pull, a create() echo, or the session-added frame) and dies with
|
||||
* the prune when the row leaves.
|
||||
* prune share one predicate (decision 12): listed on the host or selected
|
||||
* through a retained subagent address. Breadcrumb-only ancestors remain
|
||||
* summary data and do not keep scopes alive.
|
||||
*/
|
||||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||||
const existing = this.scopes.get(id)
|
||||
@@ -559,14 +597,17 @@ export class SessionsService implements ISessions {
|
||||
return record
|
||||
}
|
||||
|
||||
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
|
||||
/** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
|
||||
private eligible(id: SessionId): boolean {
|
||||
return this.list.getSnapshot().byId[id] !== undefined
|
||||
const { ids, current } = this.list.getSnapshot()
|
||||
return current === id || ids.includes(id)
|
||||
}
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const { items, current, phase } = this.manager.getListSnapshot()
|
||||
const {
|
||||
items, current, phase, subagentsByParent, currentAddress,
|
||||
} = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
@@ -575,12 +616,48 @@ export class SessionsService implements ISessions {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
waitingApproval: entry.waitingApproval,
|
||||
...(entry.completed ? { completed: true } : {}),
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.pendingInteraction === undefined
|
||||
? {}
|
||||
: { pendingInteraction: entry.pendingInteraction }),
|
||||
...(entry.projectionValues === undefined
|
||||
? {}
|
||||
: { projectionValues: entry.projectionValues }),
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
||||
}
|
||||
}
|
||||
if (current !== undefined && currentAddress !== undefined) {
|
||||
const seen = new Set<SessionId>()
|
||||
let address: SubagentAddress | undefined = currentAddress
|
||||
while (address !== undefined && !seen.has(address.childSessionId)) {
|
||||
const childId = address.childSessionId
|
||||
seen.add(childId)
|
||||
const child = subagentsByParent[address.parentSessionId]?.entries
|
||||
.find(entry => entry.kind === 'child' && entry.id === childId)
|
||||
if (child?.kind !== 'child') break
|
||||
const displayTitle = child.label ?? childId
|
||||
const summary = byId[childId]
|
||||
if (summary === undefined) {
|
||||
byId[childId] = {
|
||||
id: childId,
|
||||
displayTitle,
|
||||
parentId: address.parentSessionId,
|
||||
origin: 'subagent',
|
||||
running: child.activity === 'running',
|
||||
blank: false,
|
||||
updatedAt: 0,
|
||||
}
|
||||
} else if (summary.displayTitle !== displayTitle) {
|
||||
byId[childId] = { ...summary, displayTitle }
|
||||
}
|
||||
const parent = byId[address.parentSessionId]
|
||||
if (parent !== undefined && parent.origin !== 'subagent') break
|
||||
address = this.manager.navigationAddress(address.parentSessionId)
|
||||
}
|
||||
}
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
@@ -588,16 +665,22 @@ export class SessionsService implements ISessions {
|
||||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||||
if (current === undefined) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
} else if (byId[current] !== undefined
|
||||
&& (persisted !== current
|
||||
|| this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|
||||
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
|
||||
|| this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
|
||||
this.selection.set({
|
||||
sessionId: current,
|
||||
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
||||
})
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase })
|
||||
this.pruneScopes(byId)
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
|
||||
this.pruneScopes()
|
||||
}
|
||||
|
||||
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
||||
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
||||
void byId
|
||||
private pruneScopes(): void {
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (this.eligible(id)) continue
|
||||
if (id === this.watched) {
|
||||
|
||||
@@ -5,8 +5,8 @@ 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, InboxItemId, MuxFrame, QueueAction, RpcError,
|
||||
RpcId, RpcResult, SessionId, ToolEventView,
|
||||
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
|
||||
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -21,7 +21,7 @@ import { PendingWait } from './pending.ts'
|
||||
import { TranscriptAdapter } from './transcript-adapter.ts'
|
||||
import { displayFailureMessage } from './failure-display.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
|
||||
@@ -34,6 +34,10 @@ const MAX_RETRY_DELAY_MS = 2_147_483_647
|
||||
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
/** Catalog-discovered address selecting non-activating subagent transport. */
|
||||
address?: SubagentAddress
|
||||
/** Whether the exact direct parent Agent was live at the latest catalog read. */
|
||||
parentAvailable?: boolean
|
||||
/**
|
||||
* First ACCEPTED prompt on a blank session (fires at most once, on the
|
||||
* prompt RPC's success response): the manager mirrors the blank→false flip
|
||||
@@ -94,6 +98,8 @@ export class Session implements SessionFace {
|
||||
private readonly transcript = new TranscriptAdapter()
|
||||
private partial: PartialAccumulator | null = null
|
||||
private openCalls = new Map<string, RunningToolCall>()
|
||||
/** Last entered step per turn, folded from step/start for terminal error placement. */
|
||||
private lastStepByTurn = new Map<number, number>()
|
||||
/** 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. */
|
||||
@@ -109,6 +115,16 @@ export class Session implements SessionFace {
|
||||
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
|
||||
private derivedRev = 0
|
||||
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Exact turn timing retained from the raw window so presentation never
|
||||
* infers elapsed time from transcript content. */
|
||||
private turnTimings = new Map<number, { startTime: number; endTime?: number }>()
|
||||
private turnTimingsRev = 0
|
||||
private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null
|
||||
/** Completed turn boundaries retained from the raw window so presentation
|
||||
* actions never infer a safe fork point from transcript content alone. */
|
||||
private turnEnds = new Map<number, number>()
|
||||
private turnEndsRev = 0
|
||||
private turnEndsCache: { rev: number; value: ReadonlyMap<number, number> } | null = null
|
||||
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
|
||||
private queued: QueuedMessage[] = []
|
||||
private queueRev = 0
|
||||
@@ -119,6 +135,8 @@ export class Session implements SessionFace {
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
private running = false
|
||||
private address: SubagentAddress | undefined
|
||||
private parentAvailable = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
* synchronously before prompt()'s first await, never reset — the blank →
|
||||
@@ -174,6 +192,8 @@ export class Session implements SessionFace {
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.address = options.address
|
||||
this.parentAvailable = options.parentAvailable ?? false
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -213,7 +233,21 @@ export class Session implements SessionFace {
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
|
||||
if (this.address === undefined) {
|
||||
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
|
||||
} else if (this.address.mode === 'one-shot') {
|
||||
result = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-not-resumable',
|
||||
message: 'one-shot subagent conversations are read-only',
|
||||
details: { childSessionId: this.address.childSessionId },
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
}
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
@@ -239,7 +273,7 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/** Apply one operation to a still-pending queue occurrence. */
|
||||
async updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
|
||||
async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
|
||||
try {
|
||||
return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result
|
||||
} catch (error) {
|
||||
@@ -253,6 +287,19 @@ export class Session implements SessionFace {
|
||||
* @returns the cancel result.
|
||||
*/
|
||||
async cancel(): Promise<RpcResult<{ accepted: true }>> {
|
||||
if (this.address !== undefined) {
|
||||
const result: RpcResult<{ accepted: true }> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-delivery-unavailable',
|
||||
message: 'subagent activation cancellation is unavailable',
|
||||
details: { childSessionId: this.address.childSessionId },
|
||||
},
|
||||
}
|
||||
this.promptError = { op: 'stop', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
|
||||
@@ -318,9 +365,7 @@ export class Session implements SessionFace {
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
|
||||
})
|
||||
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
|
||||
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
@@ -413,6 +458,9 @@ export class Session implements SessionFace {
|
||||
case 'session/queue': {
|
||||
this.queued = frame.items.map(item => ({
|
||||
id: item.id,
|
||||
messageId: item.message.id,
|
||||
placement: item.placement,
|
||||
content: item.message.content,
|
||||
preview: queuePreviewOf(item.message.content),
|
||||
text: queueTextOf(item.message.content),
|
||||
}))
|
||||
@@ -479,6 +527,32 @@ export class Session implements SessionFace {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or clear the catalog-discovered transport address. A changed
|
||||
* address rebuilds an already-open window through its new history route.
|
||||
* @param address - direct parent/child address, or undefined for ordinary transport.
|
||||
* @param parentAvailable - latest exact-parent availability hint.
|
||||
*/
|
||||
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
|
||||
const same = this.address?.parentSessionId === address?.parentSessionId
|
||||
&& this.address?.childSessionId === address?.childSessionId
|
||||
&& this.address?.mode === address?.mode
|
||||
this.address = address
|
||||
this.parentAvailable = parentAvailable
|
||||
if (!same && this.openState !== 'cold') void this.resync()
|
||||
else this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the parent availability hint from a catalog refresh.
|
||||
* @param available - whether the exact direct parent is live.
|
||||
*/
|
||||
handleSubagentParentAvailable(available: boolean): void {
|
||||
if (this.parentAvailable === available) return
|
||||
this.parentAvailable = available
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank-bit relay from the authoritative summary source (list baseline and
|
||||
* the session-added frame). Monotone: once any signal (local first send,
|
||||
@@ -533,7 +607,7 @@ export class Session implements SessionFace {
|
||||
this.openError = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
let { result } = await this.history({ maxMessages: PAGE_MESSAGES })
|
||||
if (generation !== this.openGeneration) return
|
||||
if (!result.ok) {
|
||||
this.openState = 'error'
|
||||
@@ -544,7 +618,7 @@ export class Session implements SessionFace {
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
result = (await this.history({ maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
@@ -588,9 +662,21 @@ export class Session implements SessionFace {
|
||||
this.events.push(event)
|
||||
this.views.push(view)
|
||||
this.transcript.append(event, view)
|
||||
this.handoffPendingSteering(event)
|
||||
this.applyEventSideEffects(event, view)
|
||||
}
|
||||
|
||||
/** Retire the first matching live steering occurrence when its durable message takes over. */
|
||||
private handoffPendingSteering(event: SessionEvent): void {
|
||||
if (event.type !== 'user/message') return
|
||||
const message = event.data
|
||||
const index = this.queued.findIndex(item =>
|
||||
item.placement === 'steering' && item.messageId === message.id)
|
||||
if (index === -1) return
|
||||
this.queued = this.queued.filter((_item, candidate) => candidate !== index)
|
||||
this.queueRev++
|
||||
}
|
||||
|
||||
/** 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). The window stays one contiguous
|
||||
@@ -609,6 +695,10 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
this.appendLive(event, view)
|
||||
if (event.type === 'assistant/chunk') {
|
||||
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
|
||||
return
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -621,7 +711,7 @@ export class Session implements SessionFace {
|
||||
this.stitching = true
|
||||
const generation = this.openGeneration
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
@@ -716,12 +806,17 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
|
||||
case 'turn/start':
|
||||
this.lastStepByTurn.set(event.data.turn, 0)
|
||||
this.turnTimings.set(event.data.turn, { startTime: event.time })
|
||||
this.turnTimingsRev++
|
||||
return
|
||||
case 'step/start':
|
||||
this.lastStepByTurn.set(event.data.turn, event.data.step)
|
||||
return
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
this.settleScheduledRetry('started', turn)
|
||||
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
|
||||
this.partial = new PartialAccumulator(turn, step)
|
||||
}
|
||||
@@ -748,25 +843,34 @@ export class Session implements SessionFace {
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
|
||||
const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0
|
||||
const timing = this.turnTimings.get(event.data.turn)
|
||||
if (timing !== undefined) {
|
||||
this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time })
|
||||
this.turnTimingsRev++
|
||||
}
|
||||
this.turnEnds.set(event.data.turn, event.seq)
|
||||
this.turnEndsRev++
|
||||
if (event.data.reason.kind === 'aborted') {
|
||||
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
|
||||
const failure = event.data.reason.error
|
||||
this.derivedNodes.push({
|
||||
kind: 'turn-error',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
turn: event.data.turn,
|
||||
step: event.data.reason.step,
|
||||
step: lastStep,
|
||||
message: displayFailureMessage(failure),
|
||||
...(failure.code === undefined ? {} : { code: failure.code }),
|
||||
code: failure.code,
|
||||
})
|
||||
this.derivedRev++
|
||||
}
|
||||
if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn)
|
||||
// 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
|
||||
@@ -801,6 +905,7 @@ export class Session implements SessionFace {
|
||||
})
|
||||
this.derivedRev++
|
||||
}
|
||||
this.lastStepByTurn.delete(event.data.turn)
|
||||
return
|
||||
}
|
||||
default:
|
||||
@@ -835,9 +940,14 @@ export class Session implements SessionFace {
|
||||
private rebuildDerivedFromWindow(): void {
|
||||
this.partial = null
|
||||
this.openCalls.clear()
|
||||
this.lastStepByTurn.clear()
|
||||
this.callsRev++
|
||||
this.derivedNodes = []
|
||||
this.derivedRev++
|
||||
this.turnTimings = new Map()
|
||||
this.turnTimingsRev++
|
||||
this.turnEnds = new Map()
|
||||
this.turnEndsRev++
|
||||
this.codeDispatches = new Map()
|
||||
this.dispatchesRev++
|
||||
for (let i = 0; i < this.events.length; i++) {
|
||||
@@ -869,6 +979,12 @@ export class Session implements SessionFace {
|
||||
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
|
||||
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
|
||||
}
|
||||
if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) {
|
||||
this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) }
|
||||
}
|
||||
if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) {
|
||||
this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) }
|
||||
}
|
||||
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
|
||||
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
|
||||
}
|
||||
@@ -882,12 +998,17 @@ export class Session implements SessionFace {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
turnTimings: this.turnTimingsCache.value,
|
||||
turnEnds: this.turnEndsCache.value,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
pending: this.pendingCache.value,
|
||||
codeDispatches: this.dispatchesCache.value,
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
subagent: this.address === undefined
|
||||
? null
|
||||
: { address: this.address, parentAvailable: this.parentAvailable },
|
||||
composerPhase: derivePhase(
|
||||
// Command lifecycle nodes are not conversation: running /permission
|
||||
// or /plan on a fresh session keeps the hero (the client mirror of
|
||||
@@ -905,6 +1026,17 @@ export class Session implements SessionFace {
|
||||
lastAgentError: this.lastAgentError,
|
||||
}
|
||||
}
|
||||
|
||||
/** Select ordinary or addressed history transport from the stored browser fact. */
|
||||
private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise<RpcResponse<{
|
||||
events: HistoryEntry[]
|
||||
hasMore: boolean
|
||||
projections?: ProjectionsBaseline
|
||||
}>> {
|
||||
return this.address === undefined
|
||||
? this.api.sessions.history({ sessionId: this.sessionId, ...payload })
|
||||
: this.api.subagents.history({ ...this.address, ...payload })
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the plugin-owned payload at the session-event wire boundary. */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Reconstruct durable steering identity from the event-sourced agent inbox. */
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
type InboxTarget = 'next-turn' | 'next-step'
|
||||
|
||||
/** Minimal pending identity retained while replaying durable inbox splices. */
|
||||
interface PendingIdentity {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
/** Client-side structural view of the host-owned inbox event. */
|
||||
interface InboxSplice {
|
||||
readonly target: InboxTarget
|
||||
readonly start: number
|
||||
readonly removedCount?: number
|
||||
readonly inserted: readonly PendingIdentity[]
|
||||
readonly outcome?: 'canceled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally identifies `user/message` events claimed from the next-step
|
||||
* inbox. The agent loop records all admitted input as `user/message`; the
|
||||
* preceding `agent/inbox/spliced` events preserve whether it came from the
|
||||
* queued-turn list or the next-step list.
|
||||
*/
|
||||
export class SteeringHistory {
|
||||
private readonly inbox: Record<InboxTarget, PendingIdentity[]> = {
|
||||
'next-turn': [],
|
||||
'next-step': [],
|
||||
}
|
||||
|
||||
private readonly claimedNextStep = new Set<string>()
|
||||
|
||||
/** Clear all replay state before rebuilding a history window. */
|
||||
reset(): void {
|
||||
this.inbox['next-turn'] = []
|
||||
this.inbox['next-step'] = []
|
||||
this.claimedNextStep.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one event and report whether it is a durable human steering message.
|
||||
* @param event - next raw session event in sequence order.
|
||||
* @returns true only for a user-origin message previously claimed from `next-step`.
|
||||
*/
|
||||
apply(event: SessionEvent): boolean {
|
||||
if ((event.type as string) === 'agent/inbox/spliced') {
|
||||
this.applySplice(event.data as unknown as InboxSplice)
|
||||
return false
|
||||
}
|
||||
if (event.type !== 'user/message') return false
|
||||
const id = event.data.id
|
||||
if (!this.claimedNextStep.delete(id)) return false
|
||||
return event.data.source.kind === 'user'
|
||||
}
|
||||
|
||||
/** Replay one host-validated inbox splice. */
|
||||
private applySplice({ target, start, removedCount = 0, inserted, outcome }: InboxSplice): void {
|
||||
const removed = this.inbox[target].splice(start, removedCount, ...inserted)
|
||||
for (const identity of inserted) this.claimedNextStep.delete(identity.id)
|
||||
if (target !== 'next-step' || outcome === 'canceled') return
|
||||
for (const identity of removed) this.claimedNextStep.add(identity.id)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo
|
||||
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'
|
||||
import { contextForm, contextProvenance } from './context-provenance.ts'
|
||||
import { SteeringHistory } from './steering-history.ts'
|
||||
import type { AssistantStepMetadata } from './assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts'
|
||||
|
||||
/**
|
||||
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
|
||||
@@ -29,7 +33,6 @@ import { toAssistantBlocks } from './conversation.ts'
|
||||
* 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'
|
||||
|
||||
@@ -45,11 +48,13 @@ interface CallIndexEntry {
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the eight-variant ConversationNode union). */
|
||||
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
steering: boolean,
|
||||
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
@@ -60,6 +65,15 @@ function materializeNode(
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
provenance: contextProvenance(event.data.source),
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
if (steering) {
|
||||
return {
|
||||
kind: 'steering', messageId: event.data.id,
|
||||
seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -71,11 +85,7 @@ function materializeNode(
|
||||
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,
|
||||
timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time),
|
||||
}
|
||||
case 'tool/result': {
|
||||
const result = event.data.message.content[0]
|
||||
@@ -176,8 +186,12 @@ export class TranscriptAdapter {
|
||||
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
|
||||
private projected: ConversationNode[] = []
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */
|
||||
private stepTimings = new Map<string, AssistantStepMetadata>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/** Durable inbox replay used to distinguish next-step human input from queued prompts. */
|
||||
private readonly steeringHistory = new SteeringHistory()
|
||||
/**
|
||||
* Command lifecycle nodes by commandId (insertion = run order). The
|
||||
* `command/run`/`command/done` pair is log-only, so it is not a surface
|
||||
@@ -206,6 +220,9 @@ export class TranscriptAdapter {
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
this.steeringHistory.reset()
|
||||
const steeringSeqs = new Set<number>()
|
||||
this.stepTimings = 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. */
|
||||
@@ -213,12 +230,14 @@ export class TranscriptAdapter {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq)
|
||||
indexAssistantStepTiming(this.stepTimings, 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))
|
||||
if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
|
||||
}
|
||||
this.projected = projected
|
||||
}
|
||||
@@ -235,9 +254,11 @@ export class TranscriptAdapter {
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, view)
|
||||
const steering = this.steeringHistory.apply(event)
|
||||
indexAssistantStepTiming(this.stepTimings, event)
|
||||
if (this.indexCommand(event)) this.rev++
|
||||
if (!isTranscriptEvent(event)) return
|
||||
this.projected = [...this.projected, this.materialize(event)]
|
||||
this.projected = [...this.projected, this.materialize(event, steering)]
|
||||
this.rev++
|
||||
}
|
||||
|
||||
@@ -270,10 +291,16 @@ export class TranscriptAdapter {
|
||||
}
|
||||
|
||||
/** Materialize one transcript event against the complete current indexes. */
|
||||
private materialize(event: SessionEvent): ConversationNode {
|
||||
private materialize(event: SessionEvent, steering: boolean): ConversationNode {
|
||||
return isCompactCheckpoint(event)
|
||||
? materializeCompaction(event, this.eventIndex)
|
||||
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null)
|
||||
: materializeNode(
|
||||
event,
|
||||
this.callIdx,
|
||||
this.resultViews.get(event.seq) ?? null,
|
||||
steering,
|
||||
this.stepTimings,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* SlotsService: the cordis Service layer of the slot system over the pure
|
||||
* SlotCore (ui-slots owns registration semantics, the declaration ledger,
|
||||
* the load-time validations, and the unload cascade). This layer owns what
|
||||
* needs the runtime: the 'slots/changed' event bridge, register through the
|
||||
* caller's ctx.effect (fiber unload collects registrations), the renderer
|
||||
* install seam (install()/renderSlot('root') + the SlotRendererHost face),
|
||||
* and the store INSTANCE axis — handle x scope key -> create/cache, dropped
|
||||
* with the last holding entry, session instances cleared (with persisted
|
||||
* state) on scope death.
|
||||
* needs the runtime: the 'slots/changed' event bridge, register and
|
||||
* declaration injection through the caller's ctx.effect (fiber unload
|
||||
* collects both), the renderer install seam (install()/renderSlot('root') +
|
||||
* the SlotRendererHost face), and the store INSTANCE axis — handle x scope
|
||||
* key -> create/cache, dropped with the last holding entry, session instances
|
||||
* cleared (with persisted state) on scope death.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-redundant-type-constituents --
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
|
||||
@@ -78,6 +78,9 @@ interface ErasedRegisterOptions {
|
||||
/** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */
|
||||
interface ErasedCore { register(options: object, component: unknown): () => void }
|
||||
|
||||
/** One synchronous effect installed while an injected slot declaration is live. */
|
||||
type SlotInjectionEffect = (() => void) | Iterable<() => void, void, void>
|
||||
|
||||
/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */
|
||||
export class SlotsService extends Service {
|
||||
private readonly _core = new SlotCore()
|
||||
@@ -114,6 +117,85 @@ export class SlotsService extends Service {
|
||||
*/
|
||||
declare readonly register: SlotCore['register']
|
||||
|
||||
/**
|
||||
* Install an effect for each declaration lifetime of a slot. The callback
|
||||
* runs synchronously when the declaration already exists; otherwise it runs
|
||||
* inside the declaring `register()` call after the declaration is committed.
|
||||
* Collapse disposes the effect and a later declaration runs it again.
|
||||
* Callback effects are synchronous disposers; iterable effects install
|
||||
* transactionally and dispose in reverse order. The controller belongs to
|
||||
* the caller's fiber, so plugin unload cancels a pending wait and removes any
|
||||
* active contribution.
|
||||
*
|
||||
* @param key - declared SlotMap key to depend on.
|
||||
* @param callback - creates one disposer or an iterable of disposers.
|
||||
* @returns idempotent disposer for the wait and active effect.
|
||||
* @throws callback setup failures synchronously when the slot is already declared.
|
||||
*/
|
||||
inject(key: keyof SlotMap & string, callback: () => SlotInjectionEffect): () => void {
|
||||
const ctx = this.ctx
|
||||
const disposeController = ctx.effect(() => {
|
||||
let active: (() => void) | undefined
|
||||
let activeEpoch: number | undefined
|
||||
let stopped = false
|
||||
let unsubscribe = (): void => {}
|
||||
|
||||
const stop = (): void => {
|
||||
if (stopped) return
|
||||
// Failure callers retire the injection permanently: a delayed setup
|
||||
// failure never retries on a later declaration.
|
||||
stopped = true
|
||||
unsubscribe()
|
||||
const dispose = active
|
||||
active = undefined
|
||||
activeEpoch = undefined
|
||||
dispose?.()
|
||||
}
|
||||
|
||||
const reconcile = (): void => {
|
||||
if (stopped) return
|
||||
const spec = this._core.specDynamic(key)
|
||||
const epoch = this._core.declarationEpoch(key)
|
||||
if (active !== undefined && activeEpoch === epoch) return
|
||||
const dispose = active
|
||||
active = undefined
|
||||
activeEpoch = undefined
|
||||
dispose?.()
|
||||
if (spec === undefined) return
|
||||
// A declaration lifetime is a nested Cordis effect. This gives
|
||||
// generator callbacks the same transactional setup, reverse teardown,
|
||||
// diagnostics tree, and idempotence as every other plugin effect.
|
||||
const disposeEffect = ctx.effect(callback, `slots.inject(${JSON.stringify(key)}): declaration`)
|
||||
active = () => { void disposeEffect() }
|
||||
activeEpoch = epoch
|
||||
}
|
||||
|
||||
const changed = (): void => {
|
||||
try {
|
||||
reconcile()
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
stop()
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
queueMicrotask(() => { throw failure })
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe = this._core.subscribeDeclaration(key, changed)
|
||||
try {
|
||||
reconcile()
|
||||
} catch (error) {
|
||||
stop()
|
||||
throw error
|
||||
}
|
||||
return stop
|
||||
}, `slots.inject(${JSON.stringify(key)})`)
|
||||
return () => { void disposeController() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the shell's renderer (web-react's createSlotRenderer product).
|
||||
* Boot-once: a second install throws. Runs through the caller's ctx.effect,
|
||||
|
||||
@@ -94,15 +94,19 @@ export class WorkspacesService implements IWorkspaces {
|
||||
// would miss the reuse scan and mint another hidden blank session.
|
||||
const inflight = this.connecting.get(workspaceId)
|
||||
if (inflight !== undefined) return inflight
|
||||
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
|
||||
// canon; summary cwd is the session header passthrough of the same canon).
|
||||
// An archived blank is never reused: reuse would open a session no
|
||||
// grouping surface can show, so New Session mints a fresh one instead.
|
||||
// Reuse requires workspace membership (id in sessionIds AND same
|
||||
// canonical cwd — the host's own membership rule), never cwd alone:
|
||||
// a cwd match can belong to no account (sessions the CLI/TUI birthed at
|
||||
// the host cwd, or a deleted/recreated registration) and reusing it
|
||||
// would open a session no grouping surface shows under this workspace.
|
||||
// An archived blank is never reused either: reuse would open a session
|
||||
// no grouping surface can show, so New Session mints a fresh one instead.
|
||||
const archived = this.list.getSnapshot().archivedSessionIds
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
for (const id of sessions.ids) {
|
||||
const summary = sessions.byId[id]
|
||||
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
|
||||
&& workspace.sessionIds.includes(summary.id)
|
||||
&& !archived.includes(summary.id)) return summary.id
|
||||
}
|
||||
const attempt = this.sessions.create({ workspaceId })
|
||||
|
||||
@@ -26,6 +26,7 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
|
||||
BIN
packages/client/runtime/tests/context-provenance.spec.ts
Normal file
BIN
packages/client/runtime/tests/context-provenance.spec.ts
Normal file
Binary file not shown.
@@ -12,7 +12,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
|
||||
export const ev = {
|
||||
turnStart: (seq: number, turn: number): SessionEvent =>
|
||||
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
at(seq, { type: 'turn/start', data: { turn } }),
|
||||
user: (seq: number, body: string): SessionEvent =>
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: text(body), source: { kind: 'user' },
|
||||
@@ -82,7 +82,12 @@ export const ev = {
|
||||
},
|
||||
}),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
|
||||
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
|
||||
at(seq, { type: 'turn/end', data: {
|
||||
turn,
|
||||
reason: reason === 'completed'
|
||||
? { kind: 'completed' }
|
||||
: { kind: 'aborted', reason: { kind: reason === 'disposed' ? 'disposed' : 'user' } },
|
||||
} }),
|
||||
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 =>
|
||||
|
||||
@@ -132,6 +132,19 @@ export class FakeApiClient implements IApiClient {
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
|
||||
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
onSubagentHistory: (payload: unknown) => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>>
|
||||
= () => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
|
||||
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
|
||||
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
@@ -203,7 +216,8 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
|
||||
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
|
||||
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
|
||||
@@ -218,6 +232,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
|
||||
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
|
||||
@@ -1,13 +1,89 @@
|
||||
import { createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createMessage, createUserMessage } 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 { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.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('names an injected context node from its durable source, like the live adapter', () => {
|
||||
// The fold declares its own node mapping (jscpd:ignore in the source), so
|
||||
// the provenance projection is pinned on both sides independently.
|
||||
const injected = at(0, {
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
|
||||
// A plugin source, because the client program does not see the host
|
||||
// packages that merge richer source kinds; those arms are pinned in
|
||||
// context-provenance.spec.ts.
|
||||
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
|
||||
}),
|
||||
})
|
||||
const { contexts } = projectConversationHistory([{ event: injected }])
|
||||
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'dsh-tool-skill' },
|
||||
form: 'catalog',
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects next-step human input as durable steering', () => {
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'change course' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const events = [
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
]
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes).toMatchObject([{
|
||||
kind: 'steering', messageId: steering.id, seq: 2,
|
||||
}])
|
||||
})
|
||||
|
||||
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
|
||||
const baseSeq = 400_000
|
||||
const events = [
|
||||
ev.user(baseSeq, 'loaded tail'),
|
||||
at(baseSeq + 1, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
|
||||
sourceEventSeqs: [baseSeq],
|
||||
data: {
|
||||
turn: 80,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'tail summary' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const projection = projectConversationHistory(events.map(event => ({ event })))
|
||||
expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
|
||||
expect(projection.contexts.map(context => ({
|
||||
originSeq: context.originSeq,
|
||||
nodes: context.nodes.map(node => node.seq),
|
||||
}))).toEqual([
|
||||
{ originSeq: undefined, nodes: [baseSeq] },
|
||||
{ originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
@@ -91,4 +167,35 @@ describe('projectConversationHistory', () => {
|
||||
requestConfig: { provider: 'fake', model: 'first' },
|
||||
})
|
||||
})
|
||||
|
||||
it('drops completed token payloads without changing inspection projections', () => {
|
||||
const events = [
|
||||
ev.user(0, 'before'),
|
||||
ev.stepStart(1, 1, 0),
|
||||
ev.chunkStart(2, 1),
|
||||
ev.chunkText(3, 1, ''),
|
||||
ev.chunkText(4, 1, 'first'),
|
||||
ev.chunkText(5, 1, ' discarded'),
|
||||
at(6, { type: 'assistant/chunk', data: {
|
||||
turn: 1,
|
||||
step: 0,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
|
||||
} }),
|
||||
ev.assistant(7, 1, 'first discarded'),
|
||||
ev.compactSummary(8, 'summary', 0, 7),
|
||||
ev.compactCheckpoint(9, 8, 0, 7),
|
||||
ev.stepStart(10, 2, 0),
|
||||
ev.chunkStart(11, 2),
|
||||
ev.chunkText(12, 2, 'interrupted'),
|
||||
ev.turnEnd(13, 2, 'aborted'),
|
||||
]
|
||||
const raw = events.map(event => ({ event }))
|
||||
const compacted = compactHistoryInspectionEntries(raw)
|
||||
|
||||
expect(compacted.map(entry => entry.event.seq)).toEqual([
|
||||
0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
|
||||
])
|
||||
expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
|
||||
expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,4 +52,11 @@ describe('flattenLineage', () => {
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('projects the completion-reminder set into rows (absent = false)', () => {
|
||||
const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId]))
|
||||
expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false)
|
||||
expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true)
|
||||
expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,13 @@ import { entries, plainTurn } from './event-script.ts'
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
const S2 = 'fk-m2' as SessionId
|
||||
|
||||
type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }>
|
||||
type SummaryOver = Partial<{
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
blank: boolean
|
||||
parentSessionId: SessionId
|
||||
origin: 'subagent'
|
||||
}>
|
||||
|
||||
function summary(sessionId: SessionId, over: SummaryOver = {}) {
|
||||
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
|
||||
@@ -34,6 +40,7 @@ describe('instances', () => {
|
||||
const manager = new SessionManager(api)
|
||||
// Uninstantiated: approval buffers, plain session/event drops.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
|
||||
@@ -41,16 +48,26 @@ describe('instances', () => {
|
||||
expect(manager.get(S2).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
|
||||
it('retains every live answerable request and compacts resolutions before instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// 40 distinct question frames for an uninstantiated session: only the newest 32 survive.
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
|
||||
}
|
||||
const pending = manager.get(S1).getSnapshot().pending
|
||||
expect(pending).toHaveLength(32)
|
||||
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
for (let i = 0; i < 40; i++) {
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: `r${i}` as never,
|
||||
payload: { type: 'question/resolved', sessionId: S1, questionRpcId: `q${i}` as never, outcome: 'answered' },
|
||||
})
|
||||
}
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
expect(manager.get(S1).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('drops buffered answerable requests on session removal', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
// Removed session: buffered frames must not replay on a future instantiation.
|
||||
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
|
||||
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
|
||||
@@ -272,6 +289,402 @@ describe('host frame routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent catalogs', () => {
|
||||
it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [
|
||||
summary(S1),
|
||||
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
|
||||
] as never[] }))
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
|
||||
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual({
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
// Clicking the same child through an ordinary list-selection path must not
|
||||
// erase the catalog-derived address and fall back to session.* transport.
|
||||
manager.select(S2)
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual({
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await manager.get(S2).open()
|
||||
await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: S1, childSessionId: S2, mode: 'continuable', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
{
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
content: [{ type: 'text', text: 'continue' }],
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
const listCalls = api.callsOf('subagent.list').length
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-complete' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S2, running: false },
|
||||
})
|
||||
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
|
||||
kind: 'child', id: S2, activity: 'inactive',
|
||||
})
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(listCalls)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-detached' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
|
||||
origin: 'subagent', parentSessionId: S1, running: false,
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot()).toMatchObject({
|
||||
removed: false,
|
||||
subagent: {
|
||||
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('refetches debounced membership only while the parent catalog is open', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.setSubagentCatalogOpen(S1, true)
|
||||
await Promise.resolve()
|
||||
const baseline = api.callsOf('subagent.list').length
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: S2, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added-again' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-m3' as SessionId, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
|
||||
manager.setSubagentCatalogOpen(S1, false)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added-closed' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-m4' as SessionId, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'nested-subagent' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId,
|
||||
parentSessionId: S1, origin: 'subagent', blank: false,
|
||||
},
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'ordinary-fork' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-fork' as SessionId,
|
||||
parentSessionId: S2, blank: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, hasChildren: true },
|
||||
{ kind: 'child', id: S2, hasChildren: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves a live expandability hint across only the older in-flight catalog response', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'nested-subagent' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId,
|
||||
parentSessionId: S1, origin: 'subagent', blank: false,
|
||||
},
|
||||
})
|
||||
response.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await refresh
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, hasChildren: true },
|
||||
])
|
||||
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await manager.refreshSubagents(root)
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, hasChildren: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('replays status frames over an older in-flight catalog response', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-stopped' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S1, running: false },
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-started' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S2, running: true },
|
||||
})
|
||||
response.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
|
||||
activity: 'running', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'started',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await refresh
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, activity: 'inactive' },
|
||||
{ kind: 'child', id: S2, activity: 'running' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks a detached catalog child inactive without requiring a selected address', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(S1)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-detached' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S2, activity: 'inactive' },
|
||||
])
|
||||
})
|
||||
|
||||
it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
expect(manager.refreshSubagents(root)).toBe(refresh)
|
||||
api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
first.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
await refresh
|
||||
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api, root)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
// A membership frame arrives while the pull is in flight; the debounced
|
||||
// refresh it schedules fires 50ms later and is coalesced into the pull —
|
||||
// which was requested before the new child existed. The stale mark must
|
||||
// queue one trailing pull carrying the change.
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
api.onSubagentList = () => second.promise
|
||||
first.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'older',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await refresh
|
||||
// The trailing pull is already in flight (kicked synchronously in finally).
|
||||
second.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'older',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'new child',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await second.promise
|
||||
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(2)
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, label: 'older' },
|
||||
{ kind: 'child', id: S2, label: 'new child' },
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps removal invalidation across a stale success and failed trailing pull', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const child = () => ({
|
||||
kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
|
||||
activity: 'inactive' as const, hasChildren: false,
|
||||
})
|
||||
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await refresh
|
||||
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
|
||||
|
||||
// The removal lands while a second pull is in flight: the invalidation
|
||||
// must survive the pre-removal ok response, so one trailing pull runs.
|
||||
const mid = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => mid.promise
|
||||
const midRefresh = manager.refreshSubagents(root)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'parent-removed-mid-pull' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: root },
|
||||
})
|
||||
const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => trailing.promise
|
||||
mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await midRefresh
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
|
||||
|
||||
trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} }))
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
|
||||
state: 'error',
|
||||
parentAvailable: false,
|
||||
})
|
||||
})
|
||||
|
||||
const rootCalls = api.callsOf('subagent.list')
|
||||
.filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root)
|
||||
expect(rootCalls).toHaveLength(3)
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
|
||||
})
|
||||
|
||||
it('invalidates catalog availability when the owning parent is removed', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(root)
|
||||
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'parent-removed' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: root },
|
||||
})
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('remaining branches', () => {
|
||||
it('refreshList folds a transport throw into the error state', async () => {
|
||||
const api = new FakeApiClient()
|
||||
@@ -409,9 +822,17 @@ describe('remaining branches', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1 } })
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'h2' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', blank: true, sessionId: S2,
|
||||
parentSessionId: S1, origin: 'subagent',
|
||||
},
|
||||
})
|
||||
const items = manager.getListSnapshot().items
|
||||
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
|
||||
expect(items.find(e => e.sessionId === S2)).toMatchObject({
|
||||
parentSessionId: S1, origin: 'subagent', depth: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -435,50 +856,119 @@ describe('connected generation', () => {
|
||||
expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads the durable parent address for a restored child selection', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const address = {
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
|
||||
}
|
||||
const manager = new SessionManager(api, S2, address)
|
||||
|
||||
manager.handleConnected()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
|
||||
})
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual(address)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
|
||||
describe('pending-interaction list status', () => {
|
||||
it('tracks approval requests through replay and resolution without instantiation', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
// Mux-open replay of the same question (same approvalId) is idempotent.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
|
||||
it('classifies ordinary questions and renderable plan reviews, then clears by question rpcId', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q1' as never,
|
||||
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qx' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q2' as never,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId: S1,
|
||||
questions: [{
|
||||
id: 'plan', question: 'Approve?', detail: '# Plan',
|
||||
options: [{ label: 'Approve' }, { label: 'Refuse' }],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
}],
|
||||
},
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('plan-review')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q2' as never, outcome: 'cancelled' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing detail', {}],
|
||||
['multi-select', { detail: '# Plan', multiSelect: true }],
|
||||
['more than two options', { detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Refuse' }, { label: 'Revise' }] }],
|
||||
['missing approve option', { detail: '# Plan', options: [{ label: 'Refuse' }] }],
|
||||
])('keeps an unrenderable %s plan intent on the ordinary question flow', (_name, over) => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q-plan' as never,
|
||||
payload: {
|
||||
type: 'question/requested', sessionId: S1,
|
||||
questions: [{
|
||||
id: 'plan', question: 'Approve?', options: [{ label: 'Approve' }],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
...over,
|
||||
}],
|
||||
},
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
})
|
||||
|
||||
it('the first question outranks sibling approvals and resolving it reveals the remaining wait', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'q1' as never,
|
||||
payload: { type: 'question/requested', sessionId: S1, questions: [{ id: 'name', question: 'Name?' }] },
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('question')
|
||||
manager.handleMuxEnvelope({ rpcId: 'qy' as never, payload: { type: 'question/resolved', sessionId: S1, questionRpcId: 'q1' as never, outcome: 'answered' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
// Removed sessions drop their bit outright.
|
||||
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
|
||||
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
|
||||
it('drops stale status at generation death before replay re-adds live interactions', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
// Generation death clears (resolved-while-disconnected questions send no frame)…
|
||||
manager.handleDisconnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
|
||||
// …and a replayed frame arriving before onConnected (stream open precedes
|
||||
// the readiness handshake) survives the later handleConnected untouched.
|
||||
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
|
||||
manager.handleConnected()
|
||||
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
|
||||
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
|
||||
})
|
||||
|
||||
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
|
||||
@@ -495,3 +985,128 @@ describe('waiting-approval list bit', () => {
|
||||
expect(session.getSnapshot().pending).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('completed reminder', () => {
|
||||
const status = (rpcId: string, sessionId: SessionId, running: boolean) => ({
|
||||
rpcId: rpcId as never,
|
||||
payload: { type: 'host/session-status' as const, sessionId, running },
|
||||
})
|
||||
const added = (rpcId: string, sessionId: SessionId) => ({
|
||||
rpcId: rpcId as never,
|
||||
payload: { type: 'host/session-added' as const, sessionId, blank: false },
|
||||
})
|
||||
const entry = (manager: SessionManager, sessionId: SessionId) =>
|
||||
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
|
||||
|
||||
it('arms on a running→idle flip of a non-selected session and clears on select', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
manager.handleHostEnvelope(status('s1', S2, true))
|
||||
manager.handleHostEnvelope(status('s2', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
// Opening the session consumes the reminder.
|
||||
manager.select(S2)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
})
|
||||
|
||||
it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S2)
|
||||
manager.handleHostEnvelope(status('s1', S2, true))
|
||||
manager.handleHostEnvelope(status('s2', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder
|
||||
// Switch away; a fresh run completing again arms the reminder.
|
||||
manager.select(S1)
|
||||
manager.handleHostEnvelope(status('s3', S2, true))
|
||||
manager.handleHostEnvelope(status('s4', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
|
||||
it('a re-run disarms the reminder while running and re-arms on its completion', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
manager.handleHostEnvelope(status('s1', S2, true))
|
||||
manager.handleHostEnvelope(status('s2', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
// The user starts a new run without opening the session: running wins.
|
||||
manager.handleHostEnvelope(status('s3', S2, true))
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
manager.handleHostEnvelope(status('s4', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
|
||||
it('session-removed drops the reminder and a re-add starts clean', () => {
|
||||
const manager = new SessionManager(new FakeApiClient())
|
||||
manager.handleHostEnvelope(added('h1', S1))
|
||||
manager.handleHostEnvelope(added('h2', S2))
|
||||
manager.select(S1)
|
||||
manager.handleHostEnvelope(status('s1', S2, true))
|
||||
manager.handleHostEnvelope(status('s2', S2, false))
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
manager.handleHostEnvelope({ rpcId: 'rm' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined()
|
||||
manager.handleHostEnvelope(added('h3', S2))
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
})
|
||||
|
||||
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
|
||||
await manager.refreshList()
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
|
||||
it('never arms for sessions already idle at first observation', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.select(S1)
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
|
||||
await manager.refreshList()
|
||||
expect(entry(manager, S2)?.completed).toBe(false)
|
||||
})
|
||||
|
||||
it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshList()
|
||||
// The session finishes while the first pull is still in flight; the pull
|
||||
// response recorded it as running at pull time.
|
||||
manager.handleHostEnvelope(status('s-mid', S2, false))
|
||||
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
|
||||
await refresh
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
|
||||
it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
|
||||
api.onList = () => gate.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshList()
|
||||
// The unknown session starts and finishes while the first pull is in
|
||||
// flight; the pull-time baseline recorded it idle, so the running→idle
|
||||
// edge lives entirely inside the replayed mutations.
|
||||
manager.handleHostEnvelope(status('s-start', S2, true))
|
||||
manager.handleHostEnvelope(status('s-finish', S2, false))
|
||||
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
|
||||
await refresh
|
||||
expect(entry(manager, S2)?.completed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
|
||||
* laziness, synchronous notifyNow, and unsubscribe.
|
||||
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
|
||||
* no-listener laziness, synchronous notifyNow, and unsubscribe.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Notifier } from '../src/client/sessions/notifier.ts'
|
||||
|
||||
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Notifier', () => {
|
||||
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
|
||||
const order: string[] = []
|
||||
@@ -60,6 +64,57 @@ describe('Notifier', () => {
|
||||
expect(rebuilds).toBe(1)
|
||||
})
|
||||
|
||||
it('collapses frame-dirty changes into one cumulative frame publication', () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const order: string[] = []
|
||||
const notifier = new Notifier(() => order.push('rebuild'))
|
||||
notifier.subscribe(() => order.push('notify'))
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
|
||||
expect(order).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
frames.shift()!(0)
|
||||
expect(order).toEqual(['rebuild', 'notify'])
|
||||
})
|
||||
|
||||
it('lets a structural microtask publication supersede a pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markDirty()
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to microtask batching when animation frames are unavailable', async () => {
|
||||
let notifications = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
notifier.subscribe(() => { notifications++ })
|
||||
|
||||
notifier.markFrameDirty()
|
||||
notifier.markFrameDirty()
|
||||
expect(notifications).toBe(0)
|
||||
await microtask()
|
||||
expect(notifications).toBe(1)
|
||||
})
|
||||
|
||||
it('unsubscribed listeners stop receiving notifications', async () => {
|
||||
let calls = 0
|
||||
const notifier = new Notifier(() => undefined)
|
||||
|
||||
@@ -86,6 +86,17 @@ describe('ProjectionValueStore semantics', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
|
||||
})
|
||||
|
||||
it('publishes one reference-stable whole-value snapshot until a row changes', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
const empty = store.values()
|
||||
expect(store.values()).toBe(empty)
|
||||
store.apply('test/marks', { marks: ['a'] }, 1)
|
||||
const populated = store.values()
|
||||
expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
|
||||
expect(populated).not.toBe(empty)
|
||||
expect(store.values()).toBe(populated)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session tail-page seeding', () => {
|
||||
@@ -167,6 +178,36 @@ describe('manager frame routing', () => {
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('projects every retained value into list rows with stable snapshot identity', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{
|
||||
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
|
||||
projections: {
|
||||
asOfSeq: 2,
|
||||
values: { 'test/marks': { marks: ['baseline'] } },
|
||||
},
|
||||
}],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
const baseline = manager.getListSnapshot().items[0]?.projectionValues
|
||||
expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p2' as never,
|
||||
payload: {
|
||||
type: 'session/projection', sessionId: sid('s1'), key: 'test/marks',
|
||||
value: { marks: ['live'] }, seq: 3,
|
||||
} as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues)
|
||||
.toEqual({ 'test/marks': { marks: ['live'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
|
||||
})
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type {
|
||||
InboxItemId, MuxFrame, RpcId, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
@@ -16,12 +15,14 @@ import { FakeApiClient } from './fake-api.ts'
|
||||
const SID = 'fk-q1' as SessionId
|
||||
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
|
||||
const rid = (id: string): RpcId => id as RpcId
|
||||
const iid = (id: string): InboxItemId => id as InboxItemId
|
||||
const iid = (id: string): MessageId => id as MessageId
|
||||
|
||||
interface QueueFixture {
|
||||
id: string
|
||||
body: string
|
||||
content?: ContentBlock[]
|
||||
placement?: 'queued' | 'steering'
|
||||
message?: UserMessage
|
||||
}
|
||||
|
||||
/** Build one authoritative queue snapshot. */
|
||||
@@ -31,7 +32,8 @@ function queueFrame(items: QueueFixture[]): MuxFrame {
|
||||
sessionId: SID,
|
||||
items: items.map(item => ({
|
||||
id: iid(item.id),
|
||||
message: createUserMessage({
|
||||
placement: item.placement ?? 'queued',
|
||||
message: item.message ?? createUserMessage({
|
||||
content: item.content ?? text(item.body),
|
||||
source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
|
||||
}),
|
||||
@@ -49,8 +51,14 @@ describe('queue snapshot intake', () => {
|
||||
session.handleMuxEnvelope(rid('env-1'), queueFrame([
|
||||
{ id: 'q-1', body: '第一条 排队\n消息' },
|
||||
]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' },
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-1', placement: 'queued',
|
||||
content: [{ type: 'text', text: '第一条 排队\n消息' }],
|
||||
preview: '第一条 排队 消息', text: '第一条 排队\n消息',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -61,8 +69,14 @@ describe('queue snapshot intake', () => {
|
||||
body: '',
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
}]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-image', preview: 'hi [image]', text: null },
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-image', placement: 'queued',
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
|
||||
preview: 'hi [image]', text: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -85,8 +99,14 @@ describe('queue snapshot intake', () => {
|
||||
session.handleMuxEnvelope(rid('env-5'), queueFrame([
|
||||
{ id: 'q-2', body: 'two edited' },
|
||||
]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-2', preview: 'two edited', text: 'two edited' },
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-2', placement: 'queued',
|
||||
content: [{ type: 'text', text: 'two edited' }],
|
||||
preview: 'two edited', text: 'two edited',
|
||||
},
|
||||
])
|
||||
session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
@@ -99,6 +119,81 @@ describe('queue snapshot intake', () => {
|
||||
session.handleAgentError('unrelated')
|
||||
expect(session.getSnapshot().queue).toBe(before)
|
||||
})
|
||||
|
||||
it('retains steering placement and complete content in the same authoritative snapshot', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-steering'), queueFrame([
|
||||
{ id: 'q-next', body: 'later' },
|
||||
{ id: 's-now', body: 'interrupt now', placement: 'steering' },
|
||||
]))
|
||||
|
||||
expect(session.getSnapshot().queue.map(item => ({
|
||||
id: item.id, placement: item.placement, content: item.content,
|
||||
}))).toEqual([
|
||||
{ id: 'q-next', placement: 'queued', content: text('later') },
|
||||
{ id: 's-now', placement: 'steering', content: text('interrupt now') },
|
||||
])
|
||||
})
|
||||
|
||||
it('hands off exactly one current occurrence when live steering becomes durable', async () => {
|
||||
const session = makeSession()
|
||||
await session.open()
|
||||
const message = createUserMessage({
|
||||
content: text('same message'),
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
session.handleMuxEnvelope(rid('env-same-id'), queueFrame([
|
||||
{ id: 's-first', body: '', placement: 'steering', message },
|
||||
{ id: 's-second', body: '', placement: 'steering', message },
|
||||
]))
|
||||
const durable = {
|
||||
seq: 0,
|
||||
time: 1_700_000_000_000,
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: message,
|
||||
} as SessionEvent
|
||||
|
||||
session.handleMuxEnvelope(rid('env-durable'), {
|
||||
type: 'session/event', sessionId: SID, event: durable,
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
|
||||
expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1)
|
||||
|
||||
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
|
||||
{ id: 's-later', body: '', placement: 'steering', message },
|
||||
]))
|
||||
session.handleMuxEnvelope(rid('env-replayed-durable'), {
|
||||
type: 'session/event', sessionId: SID, event: durable,
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
|
||||
})
|
||||
|
||||
it('hands off live steering when the agent claims it as a user message', async () => {
|
||||
const session = makeSession()
|
||||
await session.open()
|
||||
const message = createUserMessage({
|
||||
content: text('claimed steering'),
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
session.handleMuxEnvelope(rid('env-claimed'), queueFrame([
|
||||
{ id: 's-claimed', body: '', placement: 'steering', message },
|
||||
]))
|
||||
|
||||
session.handleMuxEnvelope(rid('env-user-message'), {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: {
|
||||
seq: 0,
|
||||
time: 1_700_000_000_000,
|
||||
type: 'user/message',
|
||||
surfaceOp: 'append',
|
||||
data: message,
|
||||
},
|
||||
})
|
||||
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue operation transport', () => {
|
||||
@@ -110,11 +205,20 @@ describe('queue operation transport', () => {
|
||||
|
||||
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
|
||||
.resolves.toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('session.updateQueue')).toEqual([{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'edit', content: text('next') },
|
||||
}])
|
||||
await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
|
||||
.resolves.toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('session.updateQueue')).toEqual([
|
||||
{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'edit', content: text('next') },
|
||||
},
|
||||
{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'steer' },
|
||||
},
|
||||
])
|
||||
expect(session.getSnapshot().queue).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,6 +85,56 @@ describe('inspectRequests', () => {
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('does not promote a truncated resume or change header to the initial prompt', () => {
|
||||
for (const reason of ['resume', 'change'] as const) {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(10, 'step/start', { turn: 3, step: 1 }),
|
||||
at(11, 'request/header', {
|
||||
reason,
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'tail-window prompt',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[0]).toMatchObject({
|
||||
purpose: 'assistant',
|
||||
prompt: { system: 'tail-window prompt' },
|
||||
})
|
||||
expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a prompt change once the preceding header is loaded', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'before',
|
||||
},
|
||||
}),
|
||||
at(2, 'step/start', { turn: 1, step: 2 }),
|
||||
at(3, 'request/header', {
|
||||
reason: 'change',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'after',
|
||||
},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.requests[1]).toMatchObject({
|
||||
promptChange: {
|
||||
seq: 3,
|
||||
kind: 'system',
|
||||
previous: { system: 'before' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a standalone compaction owner without widening assistant turns', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'compact/start', { turn: null }),
|
||||
@@ -225,20 +275,15 @@ describe('inspectRequests', () => {
|
||||
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',
|
||||
},
|
||||
turn: 1, reason: { kind: 'error', error: {
|
||||
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' },
|
||||
turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
|
||||
}),
|
||||
]))
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ function histResponse(events: SessionEvent[], hasMore = false) {
|
||||
}
|
||||
|
||||
describe('SessionHistorySource', () => {
|
||||
it('loads every older page without changing a Chat session', async () => {
|
||||
it('loads the tail first and prepends older pages on demand', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
@@ -30,10 +30,21 @@ describe('SessionHistorySource', () => {
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(1)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
expect(source.getSnapshot().baseSeq).toBe(12)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([13, 15])
|
||||
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(true)
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(source.getSnapshot().hasMore).toBe(false)
|
||||
expect(source.getSnapshot().baseSeq).toBe(0)
|
||||
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
|
||||
.toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
@@ -42,7 +53,7 @@ describe('SessionHistorySource', () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
const before = source.getSnapshot()
|
||||
|
||||
source.handleMuxFrame({
|
||||
@@ -60,7 +71,7 @@ describe('SessionHistorySource', () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
@@ -132,13 +143,14 @@ describe('SessionHistorySource', () => {
|
||||
}))
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
|
||||
await source.loadAll()
|
||||
await source.loadTail()
|
||||
expect(await source.loadOlder()).toBe(false)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('observes consumer cancellation between older pages', async () => {
|
||||
it('finishes an already started older page after consumer cancellation', async () => {
|
||||
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const olderStarted = deferred<undefined>()
|
||||
const api = new FakeApiClient()
|
||||
@@ -151,7 +163,8 @@ describe('SessionHistorySource', () => {
|
||||
}
|
||||
const source = new SessionHistorySource(SID, api)
|
||||
const controller = new AbortController()
|
||||
const complete = source.loadAll(controller.signal)
|
||||
await source.loadTail(controller.signal)
|
||||
const complete = source.loadOlder(controller.signal)
|
||||
await olderStarted.promise
|
||||
controller.abort()
|
||||
middle.resolve(ok({
|
||||
@@ -159,7 +172,7 @@ describe('SessionHistorySource', () => {
|
||||
hasMore: true,
|
||||
}))
|
||||
|
||||
await complete
|
||||
expect(await complete).toBe(true)
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(source.getSnapshot().hasMore).toBe(true)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* enough.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, 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'
|
||||
@@ -18,6 +18,11 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
const PARENT = 'fk-parent' as SessionId
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api) }
|
||||
@@ -41,6 +46,11 @@ describe('open', () => {
|
||||
expect(snapshot.openState).toBe('open')
|
||||
expect(snapshot.hasMore).toBe(true)
|
||||
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
|
||||
expect(snapshot.turnTimings.get(3)).toEqual({
|
||||
startTime: 1_700_000_000_010,
|
||||
endTime: 1_700_000_000_015,
|
||||
})
|
||||
expect(snapshot.turnEnds.get(3)).toBe(15)
|
||||
})
|
||||
|
||||
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
|
||||
@@ -162,7 +172,41 @@ 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 () => {
|
||||
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
const { session } = await opened()
|
||||
const published: Array<string | null> = []
|
||||
session.subscribe(() => {
|
||||
const block = session.getSnapshot().partial?.blocks[0]
|
||||
published.push(block?.kind === 'text' ? block.text : null)
|
||||
})
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
|
||||
feed(ev.chunkStart(6, 1))
|
||||
feed(ev.chunkText(7, 1, '累'))
|
||||
feed(ev.chunkText(8, 1, '计'))
|
||||
expect(published).toEqual([])
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计'])
|
||||
|
||||
feed(ev.chunkText(9, 1, '完成'))
|
||||
feed(ev.assistant(10, 1, '累计完成'))
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual(['累计', null])
|
||||
|
||||
frames.shift()!(0)
|
||||
expect(published).toEqual(['累计', null])
|
||||
})
|
||||
|
||||
it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
const retryTurn = [
|
||||
@@ -171,25 +215,13 @@ describe('live event path', () => {
|
||||
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),
|
||||
ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'),
|
||||
ev.chunkStart(12, 1),
|
||||
ev.assistant(13, 1, '完整回复'),
|
||||
ev.stepEnd(14, 1),
|
||||
ev.turnEnd(15, 1),
|
||||
]
|
||||
for (const event of retryTurn.slice(0, 7)) feed(event)
|
||||
for (const event of retryTurn.slice(0, 6)) feed(event)
|
||||
|
||||
let snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
@@ -208,17 +240,27 @@ describe('live event path', () => {
|
||||
})
|
||||
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
|
||||
|
||||
for (const event of retryTurn.slice(7)) feed(event)
|
||||
for (const event of retryTurn.slice(6)) 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 retryStart = retryTurn.find(event => event.type === 'turn/start')
|
||||
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start')
|
||||
const retryEnd = retryTurn.find(event =>
|
||||
event.type === 'turn/end' && event.data.turn === retryStart.data.turn)
|
||||
if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn')
|
||||
expect(snapshot.turnTimings.get(retryStart.data.turn)).toEqual({
|
||||
startTime: retryStart.time,
|
||||
endTime: retryEnd.time,
|
||||
})
|
||||
|
||||
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().turnTimings).toEqual(snapshot.turnTimings)
|
||||
expect(replay.session.getSnapshot().partial).toBeNull()
|
||||
})
|
||||
|
||||
@@ -230,35 +272,33 @@ describe('live event path', () => {
|
||||
const failedTurns = [
|
||||
ev.turnStart(6, 1),
|
||||
ev.user(7, '鉴权失败'),
|
||||
at(8, {
|
||||
ev.stepStart(8, 1),
|
||||
at(9, {
|
||||
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',
|
||||
},
|
||||
},
|
||||
data: { turn: 1, reason: { kind: 'error', error: {
|
||||
code: 'AUTH',
|
||||
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ev.turnStart(9, 2),
|
||||
ev.user(10, '内部失败'),
|
||||
at(11, {
|
||||
ev.turnStart(10, 2),
|
||||
ev.user(11, '内部失败'),
|
||||
ev.stepStart(12, 2, 1),
|
||||
at(13, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } },
|
||||
data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } },
|
||||
}),
|
||||
]
|
||||
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' },
|
||||
{ seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
|
||||
// Every failed turn carries a structured failure; unstructured errors
|
||||
// flatten to the UNKNOWN code.
|
||||
{ seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' },
|
||||
])
|
||||
expect('code' in errors[1]!).toBe(false)
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
|
||||
@@ -395,7 +435,7 @@ describe('live event path', () => {
|
||||
})
|
||||
|
||||
it.each(['aborted', 'disposed'] as const)(
|
||||
'marks a scheduled retry as cancelled when its failed turn ends %s',
|
||||
'marks a scheduled retry as cancelled when its failed turn receives the %s cause',
|
||||
async (reason) => {
|
||||
const { session } = await opened()
|
||||
const feed = (event: SessionEvent) => {
|
||||
@@ -415,6 +455,24 @@ describe('live event path', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('marks a scheduled retry as started when its failed turn ends with an error', 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.retry(7, 1))
|
||||
feed(at(8, {
|
||||
type: 'turn/end',
|
||||
data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } },
|
||||
}))
|
||||
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
kind: 'model-retry',
|
||||
retryState: 'started',
|
||||
})
|
||||
})
|
||||
|
||||
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 }) }
|
||||
@@ -425,6 +483,7 @@ describe('live event path', () => {
|
||||
feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.partial).toBeNull()
|
||||
expect(snapshot.turnEnds.get(1)).toBe(10)
|
||||
const frozen = snapshot.nodes.at(-1)
|
||||
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] })
|
||||
// Ordered inside the flow: after the user message (seq 7), before any later turn.
|
||||
@@ -580,6 +639,51 @@ describe('paging', () => {
|
||||
})
|
||||
|
||||
describe('prompt and cancel errors', () => {
|
||||
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
const cancelled = await session.cancel()
|
||||
|
||||
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
{
|
||||
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
|
||||
content: [{ type: 'text', text: '继续' }],
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
expect(api.callsOf('session.cancel')).toEqual([])
|
||||
expect(session.getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
|
||||
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([])
|
||||
})
|
||||
|
||||
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
|
||||
const { api, session } = makeSession()
|
||||
// The blank → engaging edge fires before the RPC settles: the first-send
|
||||
@@ -1169,6 +1273,8 @@ describe('reference stability (the memo contract)', () => {
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
expect(after.turnTimings).toBe(before.turnTimings)
|
||||
expect(after.turnEnds).toBe(before.turnEnds)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
const resolved = session.getSnapshot()
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* with derived titles), the migrated current-selection account (open
|
||||
* validation, persisted mask semantics, cell resolution), scope-tree
|
||||
* lifecycle (lazy mint / frozen survival / removed teardown with staged
|
||||
* deferral — the stage follows list.current), binding identity, ancestry
|
||||
* walk, create.
|
||||
* deferral — the stage follows list.current), binding identity, breadcrumb
|
||||
* projection, create.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -28,7 +28,14 @@ function bench(): Bench {
|
||||
}
|
||||
|
||||
/** Refresh the manager list from programmable rows and flush the microtask batch. */
|
||||
type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
|
||||
type FeedRow = {
|
||||
id: string
|
||||
cwd?: string
|
||||
parentId?: string
|
||||
origin?: 'subagent'
|
||||
running?: boolean
|
||||
blank?: boolean
|
||||
}
|
||||
|
||||
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
b.api.onList = () => Promise.resolve(ok({
|
||||
@@ -36,6 +43,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
|
||||
...(r.origin !== undefined ? { origin: r.origin } : {}),
|
||||
})),
|
||||
}) as never)
|
||||
await b.svc.refresh()
|
||||
@@ -51,12 +59,14 @@ describe('list store projection', () => {
|
||||
})
|
||||
await feedList(b, [
|
||||
{ id: 's1', cwd: '/home/u/proj-a/' },
|
||||
{ id: 's2', parentId: 's1', running: true },
|
||||
{ id: 's2', parentId: 's1', origin: 'subagent', running: true },
|
||||
])
|
||||
const state = b.svc.list.getSnapshot()
|
||||
expect(state.ids).toEqual(['s1', 's2'])
|
||||
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
|
||||
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
|
||||
expect(state.byId[sid('s2')]).toMatchObject({
|
||||
displayTitle: 's2', parentId: 's1', origin: 'subagent', running: true,
|
||||
})
|
||||
expect(state.byId[sid('s2')]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -351,18 +361,89 @@ describe('slot-store scope prune hook', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ancestry', () => {
|
||||
it('walks parentId links root-first including self; broken links stop the walk', async () => {
|
||||
describe('catalog-addressed navigation', () => {
|
||||
it('uses catalog labels for a listed addressed route', async () => {
|
||||
const b = bench()
|
||||
b.api.onSubagentList = (payload) => {
|
||||
const { parentSessionId } = payload as { parentSessionId: SessionId }
|
||||
if (parentSessionId === sid('root')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
}
|
||||
if (parentSessionId === sid('child')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: false,
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||
}
|
||||
await feedList(b, [
|
||||
{ id: 'root', cwd: '/w/app' },
|
||||
{ id: 'mid', parentId: 'root' },
|
||||
{ id: 'leaf', parentId: 'mid' },
|
||||
{ id: 'orphan', parentId: 'ghost' },
|
||||
{ id: 'root' },
|
||||
{ id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
|
||||
{ id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
|
||||
])
|
||||
expect(b.svc.ancestry(sid('leaf')).map(s => s.id)).toEqual(['root', 'mid', 'leaf'])
|
||||
expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan'])
|
||||
expect(b.svc.ancestry(sid('ghost'))).toEqual([])
|
||||
await b.svc.refreshSubagents(sid('root'))
|
||||
await b.svc.refreshSubagents(sid('child'))
|
||||
b.svc.openSubagent({
|
||||
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
|
||||
})
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
|
||||
})
|
||||
|
||||
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
|
||||
const b = bench()
|
||||
b.api.onSubagentList = (payload) => {
|
||||
const { parentSessionId } = payload as { parentSessionId: SessionId }
|
||||
if (parentSessionId === sid('root')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
}
|
||||
if (parentSessionId === sid('child')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: false,
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||
}
|
||||
await feedList(b, [{ id: 'root' }])
|
||||
await b.svc.refreshSubagents(sid('root'))
|
||||
await b.svc.refreshSubagents(sid('child'))
|
||||
b.svc.openSubagent({
|
||||
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
|
||||
})
|
||||
|
||||
const list = b.svc.list.getSnapshot()
|
||||
expect(list.ids).toEqual([sid('root')])
|
||||
expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
|
||||
expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
|
||||
expect(b.svc.binding(sid('child'))).toBeUndefined()
|
||||
expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
|
||||
|
||||
b.svc.open(sid('child'))
|
||||
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
|
||||
expect(b.svc.subagentAddress(sid('child'))).toEqual({
|
||||
parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const C: FC<object> = () => null
|
||||
*/
|
||||
interface ErasedService {
|
||||
register(options: object, component: unknown): () => void
|
||||
inject(name: string, callback: () => (() => void) | Iterable<() => void>): () => void
|
||||
install(renderer: object): void
|
||||
renderSlot(key: string, owner: object): unknown
|
||||
}
|
||||
@@ -166,6 +167,266 @@ describe('load-time validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('declaration injection', () => {
|
||||
it('activates immediately and ignores ordinary entry mutations', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
|
||||
}, C)
|
||||
const setup = vi.fn(() => bench.erased.register({ name: 't.rows', id: 'injected' }, C))
|
||||
const dispose = bench.erased.inject('t.rows', setup)
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
bench.erased.register({ name: 't.rows', id: 'ordinary' }, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
dispose()
|
||||
expect(bench.svc.entries('t.rows').map(entry => entry.options.id)).toEqual(['ordinary'])
|
||||
})
|
||||
|
||||
it('waits for declaration, cleans up on collapse, and reruns after redeclaration', async () => {
|
||||
const bench = await boot()
|
||||
const cleanup = vi.fn()
|
||||
const setup = vi.fn(() => {
|
||||
const unregister = bench.erased.register({ name: 't.host' }, C)
|
||||
return () => { unregister(); cleanup() }
|
||||
})
|
||||
bench.erased.inject('t.host', setup)
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
disposeFrame()
|
||||
await Promise.resolve()
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0)
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).toHaveBeenCalledTimes(2)
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('observes a same-tick collapse and redeclaration through the declaration epoch', async () => {
|
||||
const bench = await boot()
|
||||
const firstFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const cleanup = vi.fn()
|
||||
const setup = vi.fn(() => {
|
||||
const unregister = bench.erased.register({ name: 't.host' }, C)
|
||||
return () => { unregister(); cleanup() }
|
||||
})
|
||||
bench.erased.inject('t.host', setup)
|
||||
firstFrame()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
expect(setup).toHaveBeenCalledTimes(2)
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('plugin disposal removes an active injection and prevents a waiting one from resurrecting', async () => {
|
||||
const active = await boot()
|
||||
active.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const activeFiber = active.ctx.plugin({
|
||||
name: 'active-injection',
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, C)) },
|
||||
})
|
||||
await activeFiber.await()
|
||||
expect(active.svc.entries('t.host')).toHaveLength(1)
|
||||
await activeFiber.dispose()
|
||||
expect(active.svc.entries('t.host')).toHaveLength(0)
|
||||
|
||||
const waiting = await boot()
|
||||
const setup = vi.fn(() => waiting.erased.register({ name: 't.host' }, C))
|
||||
const waitingFiber = waiting.ctx.plugin({
|
||||
name: 'waiting-injection',
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => { ctx.slots.inject('t.host', setup) },
|
||||
})
|
||||
await waitingFiber.await()
|
||||
await waitingFiber.dispose()
|
||||
waiting.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await Promise.resolve()
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back earlier yielded registrations when generator setup fails', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
't.host': { kind: 'single', scope: 'root' },
|
||||
't.rows': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.host' }, C)
|
||||
expect(() => bench.erased.inject('t.rows', function* () {
|
||||
yield bench.erased.register({ name: 't.rows', id: 'rolled-back' }, C)
|
||||
yield bench.erased.register({ name: 't.host' }, C)
|
||||
})).toThrow(/already has a registration/)
|
||||
expect(bench.svc.entries('t.rows')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('contains and wraps a delayed setup failure so later slot listeners still run', async () => {
|
||||
const bench = await boot()
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (error: unknown): void => { failures.push(error) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const setup = vi.fn(function* () {
|
||||
yield bench.erased.register({ name: 't.host' }, C)
|
||||
throw null
|
||||
})
|
||||
bench.erased.inject('t.host', setup)
|
||||
const later = vi.fn(() => () => undefined)
|
||||
bench.erased.inject('t.host', later)
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]).toBeInstanceOf(Error)
|
||||
expect(String(failures[0])).toContain('null')
|
||||
expect(later).toHaveBeenCalledOnce()
|
||||
disposeFrame()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
expect(setup).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips a stopped controller retained by the current declaration snapshot', async () => {
|
||||
const bench = await boot()
|
||||
let stopLater = (): void => {}
|
||||
const first = vi.fn(() => {
|
||||
stopLater()
|
||||
return () => undefined
|
||||
})
|
||||
const later = vi.fn(() => () => undefined)
|
||||
bench.erased.inject('t.host', first)
|
||||
stopLater = bench.erased.inject('t.host', later)
|
||||
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
expect(first).toHaveBeenCalledOnce()
|
||||
expect(later).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a nested redeclaration activation when the outer collapse resumes', async () => {
|
||||
const bench = await boot()
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
let disposeReplacement = (): void => {}
|
||||
let replaced = false
|
||||
const first = vi.fn(() => () => {
|
||||
if (replaced) return
|
||||
replaced = true
|
||||
disposeReplacement = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
})
|
||||
const later = vi.fn(() => () => undefined)
|
||||
bench.erased.inject('t.host', first)
|
||||
bench.erased.inject('t.host', later)
|
||||
|
||||
disposeFrame()
|
||||
expect(first).toHaveBeenCalledTimes(2)
|
||||
expect(later).toHaveBeenCalledTimes(2)
|
||||
expect(bench.svc.spec('t.host')).toBeDefined()
|
||||
disposeReplacement()
|
||||
})
|
||||
|
||||
it('cancels a waiting injection when its contributor is already unloading', async () => {
|
||||
const bench = await boot()
|
||||
const setup = vi.fn(() => bench.erased.register({ name: 't.host' }, C))
|
||||
let release = (): void => {}
|
||||
const blocked = new Promise<void>((resolve) => { release = resolve })
|
||||
const pauseUnload = vi.fn(async () => { await blocked })
|
||||
const contributor = bench.ctx.plugin({
|
||||
name: 'unloading-injection',
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => {
|
||||
ctx.slots.inject('t.host', setup)
|
||||
ctx.effect(() => pauseUnload, 'pause contributor unload')
|
||||
},
|
||||
})
|
||||
await contributor.await()
|
||||
const disposing = contributor.dispose()
|
||||
expect(() => bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)).not.toThrow()
|
||||
expect(setup).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() })
|
||||
release()
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('supports dynamic plugin replacement without retaining the old rendered entry', async () => {
|
||||
const bench = await boot()
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
const componentA = (): null => null
|
||||
const componentB = (): null => null
|
||||
const mount = (name: string, component: FC<object>) => bench.ctx.plugin({
|
||||
name,
|
||||
inject: ['slots'],
|
||||
apply: (ctx: Context) => { ctx.slots.inject('t.host', () => ctx.slots.register({ name: 't.host' }, component)) },
|
||||
})
|
||||
const first = mount('replacement-a', componentA)
|
||||
await first.await()
|
||||
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentA)
|
||||
await first.dispose()
|
||||
expect(bench.svc.entries('t.host')).toHaveLength(0)
|
||||
const second = mount('replacement-b', componentB)
|
||||
await second.await()
|
||||
expect(bench.svc.entries('t.host')[0]?.component).toBe(componentB)
|
||||
})
|
||||
|
||||
it('releases service-layer store state when the declaration collapses', async () => {
|
||||
const bench = await boot()
|
||||
let host: SlotRendererHost | undefined
|
||||
bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } })
|
||||
bench.ctx.reflect.provide('sessions', fakeSessions())
|
||||
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
|
||||
const disposeFrame = bench.erased.register({
|
||||
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
|
||||
}, C)
|
||||
bench.erased.renderSlot('root', {})
|
||||
if (host === undefined) throw new Error('renderer never received the host')
|
||||
const { handle } = fakeHandle()
|
||||
bench.erased.inject('t.host', () => bench.erased.register({ name: 't.host', store: handle }, C))
|
||||
const oldEntry = host.entriesOf('t.host')[0]
|
||||
expect(host.storeOf(oldEntry as never, undefined)).toBeDefined()
|
||||
disposeFrame()
|
||||
expect(() => host?.storeOf(oldEntry as never, undefined)).toThrow(/not registered/)
|
||||
bench.erased.register({
|
||||
name: 'root', children: { 't.panel': { kind: 'single', scope: 'session' } },
|
||||
}, C)
|
||||
bench.erased.register({ name: 't.panel', store: handle }, C)
|
||||
const panelEntry = host.entriesOf('t.panel')[0]
|
||||
expect(host.storeOf(panelEntry as never, 's1')).toBeDefined()
|
||||
expect(handle.create).toHaveBeenLastCalledWith('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderer install seam', () => {
|
||||
it('throws on renderSlot before install (boot-order guidance)', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
@@ -85,29 +85,85 @@ describe('TranscriptAdapter', () => {
|
||||
|
||||
it('materializes every append-origin variant with field mapping', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: '插话' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
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(2, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }),
|
||||
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
|
||||
at(5, { 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', '结果'),
|
||||
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
|
||||
ev.toolResult(7, 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 === 'steering')).toMatchObject({ messageId: steering.id })
|
||||
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
|
||||
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies steering on the live append path', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'live steer' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
adapter.reset([])
|
||||
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
} }))
|
||||
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }))
|
||||
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
|
||||
})
|
||||
|
||||
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
|
||||
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
|
||||
const context = createUserMessage({
|
||||
content: [{ type: 'text', text: 'context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
adapter.reset([
|
||||
at(0, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, inserted: [queued],
|
||||
} }),
|
||||
at(1, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
|
||||
at(3, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [canceled],
|
||||
} }),
|
||||
at(4, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
|
||||
} }),
|
||||
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
|
||||
at(6, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, inserted: [context],
|
||||
} }),
|
||||
at(7, { type: 'agent/inbox/spliced', data: {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
} }),
|
||||
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
|
||||
])
|
||||
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
|
||||
})
|
||||
|
||||
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
|
||||
@@ -205,10 +261,15 @@ describe('TranscriptAdapter', () => {
|
||||
adapter.reset([
|
||||
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: [{ type: 'text', text: '注入的上下文' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
|
||||
}) }),
|
||||
])
|
||||
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
|
||||
expect(adapter.nodes()).toMatchObject([{
|
||||
kind: 'context',
|
||||
seq: 0,
|
||||
provenance: { role: 'inject', label: 'compact' },
|
||||
form: 'instructions',
|
||||
}])
|
||||
})
|
||||
|
||||
it('ignores a foreign plugin s replacement user/message', () => {
|
||||
@@ -415,4 +476,48 @@ describe('TranscriptAdapter', () => {
|
||||
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant timing', () => {
|
||||
const base = 1_700_000_000_000
|
||||
|
||||
it('derives step timing across a window rebuild (start + first token + completion)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.turnStart(0, 0),
|
||||
ev.user(1, '问'),
|
||||
ev.stepStart(2, 0),
|
||||
ev.chunkStart(3, 0),
|
||||
ev.chunkText(4, 0, '答'),
|
||||
ev.chunkText(5, 0, '案'),
|
||||
ev.assistant(6, 0, '答案'),
|
||||
ev.turnEnd(7, 0),
|
||||
])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the same timing on the live append path, first token winning once', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.user(0, '问')])
|
||||
adapter.append(ev.stepStart(1, 0))
|
||||
adapter.append(ev.chunkText(2, 0, '首'))
|
||||
adapter.append(ev.chunkText(3, 0, '次'))
|
||||
adapter.append(ev.assistant(4, 0, '首次'))
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
|
||||
@@ -149,26 +149,37 @@ describe('WorkspacesService', () => {
|
||||
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
|
||||
})
|
||||
|
||||
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
|
||||
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('alpha'), workspace('beta')] as never[],
|
||||
items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
|
||||
}))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
// Blank session already parked in alpha (cwd == workspace path canon).
|
||||
// Stray blank at alpha's path but NOT accounted under alpha (a CLI
|
||||
// session birthed at the host cwd), sorted before the member blank:
|
||||
// the scan must skip it and keep looking for a member hit.
|
||||
{ sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' },
|
||||
// Blank session parked in alpha (cwd == workspace path canon AND
|
||||
// accounted under alpha): the reuse hit.
|
||||
{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
|
||||
// Non-blank sibling in beta must never be reused.
|
||||
{ sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
|
||||
// Stray blank at gamma's path but NOT accounted under gamma (a CLI
|
||||
// session birthed at the host cwd): cwd alone must not hijack it —
|
||||
// reuse would open a session gamma cannot show, so New Session mints
|
||||
// a fresh accounted one instead.
|
||||
{ sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
|
||||
] as never[],
|
||||
}))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
|
||||
// Hit: same workspace → the parked blank session comes back, no create RPC.
|
||||
// Hit: same workspace → the parked member blank comes back (the earlier
|
||||
// cwd-matching non-member stray is skipped), no create RPC.
|
||||
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
|
||||
expect(api.callsOf('session.create')).toEqual([])
|
||||
// Resolution guarantee: the id is binding-resolvable synchronously.
|
||||
@@ -181,6 +192,12 @@ describe('WorkspacesService', () => {
|
||||
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
|
||||
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
|
||||
|
||||
// Miss: the stray blank matches gamma's path but is not a gamma member →
|
||||
// never reused, a fresh accounted session is created instead.
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
|
||||
await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
|
||||
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
|
||||
|
||||
// Unknown workspace fails loud instead of silently creating in nowhere.
|
||||
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
|
||||
|
||||
@@ -196,7 +213,7 @@ describe('WorkspacesService', () => {
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
|
||||
}))
|
||||
|
||||
@@ -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/schema-form/README.md
|
||||
README.md: 5dcef89cbffc8b03c3f2d874e870fa9767360d3c
|
||||
README.zh.md: a82acb7d85005da25858fb17cf42b49f06ae59db
|
||||
README.md: 716cc7ba3c24f3a4de081e2d26f803b905235fac
|
||||
README.zh.md: 65b8767df84f90eedd70e9f8ac27d7d419f06f46
|
||||
|
||||
@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. That is acceptable only because the envelope comes from the same host that serves the page; a browser schema protocol should carry a description the client cannot execute, which is deferred with the settings seam's [wire-boundary work](../../settings/settings/README.md#known-limitations-and-deferred-work).
|
||||
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it.
|
||||
- **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes.
|
||||
- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. This is safe only for an envelope from the same trusted host that serves the page; the protocol provides no inert cross-trust representation.
|
||||
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message, including its `$.path`; it does not map errors onto individual controls.
|
||||
- **No generic renderer** — consumers build feature-specific forms over these helpers. The [Web config-plane Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) records that trade-off.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包(package)不含任何 React,也不做任何渲染。
|
||||
面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React,也不做任何渲染。
|
||||
|
||||
## 契约
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。这只有在信封来自提供该页面的同一 host 时才可接受;面向浏览器的 schema 协议应当传递客户端无法执行的描述,此项与 settings seam 的[协议边界工作](../../settings/settings/README.md#known-limitations-and-deferred-work)一并暂缓。
|
||||
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。
|
||||
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。
|
||||
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback,因此 schema 信封是可执行内容,而非惰性数据。只有信封来自提供该页面的同一受信任 host 时才安全;该协议没有跨信任边界使用的惰性表示。
|
||||
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息及其 `$.path`;它不会把错误映射到各个控件。
|
||||
- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)记录该权衡。
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/test-runtime/README.md
|
||||
README.md: dc8ee8cadf5e61af15f04b1b9842af1eb658c031
|
||||
README.zh.md: a4c889d8a0291b52c8509403748df6b93567788e
|
||||
README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e
|
||||
README.zh.md: a86b9e469a5632886891628267002a14588afeaa
|
||||
|
||||
@@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
|
||||
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Every consumer is an in-repository Vitest suite; there is no Node-compatible runtime entry.
|
||||
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production projection would never produce.
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件,可接受;Node 兼容的运行时入口待出现仓外消费方再补。
|
||||
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。所有消费方都是仓内 Vitest 套件;不存在 Node 兼容的运行时入口。
|
||||
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。
|
||||
|
||||
@@ -49,8 +49,6 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -46,12 +46,15 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
|
||||
return {
|
||||
sessionId,
|
||||
nodes: [],
|
||||
turnTimings: new Map(),
|
||||
turnEnds: new Map(),
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
codeDispatches: new Map(),
|
||||
pending: [],
|
||||
queue: [],
|
||||
running: false,
|
||||
subagent: null,
|
||||
composerPhase: 'active',
|
||||
removed: false,
|
||||
openState: 'open',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
|
||||
SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
|
||||
SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// The double reports the wire schema's own search bound, like the production
|
||||
// service — a transport-varying limit would be a fiction no client can see.
|
||||
@@ -171,8 +172,12 @@ export class TestSessions implements ISessions {
|
||||
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
|
||||
private readonly channel: SessionProvideChannel
|
||||
|
||||
/** Calls observed on the service-level face (open/clear/search/fork), newest last. */
|
||||
readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = []
|
||||
/** Calls observed on the service-level face, newest last. */
|
||||
readonly calls: {
|
||||
method: 'open' | 'openSubagent' | 'setSubagentCatalogOpen' | 'refreshSubagents'
|
||||
| 'clear' | 'search' | 'fork'
|
||||
args: unknown[]
|
||||
}[] = []
|
||||
|
||||
/** The wire schema's `session.search` result bound (production parity). */
|
||||
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
|
||||
@@ -187,6 +192,7 @@ export class TestSessions implements ISessions {
|
||||
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
this.channel = new SessionProvideChannel({
|
||||
rebuildBundles: () => {
|
||||
@@ -216,7 +222,6 @@ export class TestSessions implements ISessions {
|
||||
id,
|
||||
displayTitle: fixture.id,
|
||||
running: false,
|
||||
waitingApproval: false,
|
||||
blank: false,
|
||||
updatedAt: this.records.size + 1,
|
||||
...fixture.summary,
|
||||
@@ -392,13 +397,46 @@ export class TestSessions implements ISessions {
|
||||
open(id: SessionId): void {
|
||||
this.calls.push({ method: 'open', args: [id] })
|
||||
this.require(id)
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
this.list.update((draft) => {
|
||||
draft.current = id
|
||||
draft.currentAddress = undefined
|
||||
})
|
||||
}
|
||||
|
||||
/** Open an existing fixture through its catalog address. */
|
||||
openSubagent(address: SubagentAddress): void {
|
||||
this.calls.push({ method: 'openSubagent', args: [address] })
|
||||
this.require(address.childSessionId)
|
||||
this.list.update((draft) => {
|
||||
draft.current = address.childSessionId
|
||||
draft.currentAddress = address
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve the current fixture's retained catalog address. */
|
||||
subagentAddress(id: SessionId): SubagentAddress | undefined {
|
||||
const address = this.list.getSnapshot().currentAddress
|
||||
return address?.childSessionId === id ? address : undefined
|
||||
}
|
||||
|
||||
/** Record catalog consumption; fixture callers drive snapshots explicitly. */
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
|
||||
this.calls.push({ method: 'setSubagentCatalogOpen', args: [parentSessionId, open] })
|
||||
}
|
||||
|
||||
/** Record a catalog refresh; fixture callers drive snapshots explicitly. */
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||||
this.calls.push({ method: 'refreshSubagents', args: [parentSessionId] })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** Clear the current selection (recorded; the production no-session flow). */
|
||||
clear(): void {
|
||||
this.calls.push({ method: 'clear', args: [] })
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
this.list.update((draft) => {
|
||||
draft.current = undefined
|
||||
draft.currentAddress = undefined
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user