Merge remote-tracking branch 'origin/master' into worktree/web-session-model-selector
# Conflicts: # apps/cli/package.json # apps/web/tests/smoke-real.e2e.ts # apps/web/tests/snapshots/fresh-round-trip/ui.expected.md # apps/web/tests/snapshots/seeded-history/ui.expected.md # docs/config-catalog.md # packages/client/connection/tests/fake-api.ts # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/fake-api.ts # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx # packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx # packages/client/ui-conversation/tests/chat-view.spec.tsx # packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/host/apiproxy/README.md # packages/host/apiproxy/src/api-proxy.ts # pnpm-lock.yaml # scripts/verify-package-readme-model-experience.ts # tsconfig.base.json
This commit is contained in:
@@ -7,7 +7,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
|
||||
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
|
||||
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
|
||||
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
|
||||
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
|
||||
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.
|
||||
|
||||
6
packages/README.i18n.yaml
Normal file
6
packages/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: d7427c3f9892f56185cc1175245f14a6ccea0d25
|
||||
README.zh.md: 6894aa7333f6ba4bc5723871fb77c18b5fb518a1
|
||||
@@ -1,5 +1,7 @@
|
||||
# Packages
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
|
||||
|
||||
## Hierarchy
|
||||
|
||||
56
packages/README.zh.md
Normal file
56
packages/README.zh.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 包
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类或函数插件;所有贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
|
||||
## 层级结构
|
||||
|
||||
包位于 `packages/<group>/<pkg>/`;组是容器,包名仍为 `@deepseek-ai/dsh-<pkg>`。**每个组 README 是规范的包/ctx 键映射。**
|
||||
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 |
|
||||
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 |
|
||||
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
|
||||
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |
|
||||
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 |
|
||||
| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定表面 |
|
||||
| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定表面 |
|
||||
| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定表面 |
|
||||
| [`skill/`](skill/README.md) | Skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/加载器 | 产品:稳定表面 |
|
||||
| [`compact/`](compact/README.md) | 压缩(compaction)能力系列:抽象 seam + 基础后端(工具延后) | 产品:稳定表面 |
|
||||
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 |
|
||||
| [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 |
|
||||
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 |
|
||||
| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 |
|
||||
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
|
||||
| [`todo/`](todo/README.md) | Todo/规划系列:面向模型的 `todo_write` 工具 | 产品:稳定表面 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | 人类/客户端集成:TUI 与 JSON-RPC、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
组用于区分产品 API 与支持基础设施。新包加入现有组;新组则更新其 README 和此表。
|
||||
|
||||
## 依赖
|
||||
|
||||
依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.md)(`pnpm run gen-module-graph`,CI 中有新鲜度门禁)。
|
||||
|
||||
**扩展插件依赖接口,绝不依赖具体循环。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力拆分为接口/实现/消费方包;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。
|
||||
|
||||
包 README 覆盖用途、API、扩展点和[模型体验](../docs/cookbook/adding-a-package.md#4-write-the-package-readme);列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它们还要包含 `## Known Limitations and Deferred Work`,或使用其[允许清单](../scripts/verify-package-readme-limitations.ts)。
|
||||
6
packages/acp/README.i18n.yaml
Normal file
6
packages/acp/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 326615210e5cfc39004fc5ab7462623089ac4126
|
||||
README.zh.md: 9999ecdd019501c3f501a6c69fab5e0ccfaf555c
|
||||
@@ -1,5 +1,7 @@
|
||||
# acp/ — Agent Client Protocol automation
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer.
|
||||
|
||||
| Package | Role |
|
||||
|
||||
11
packages/acp/README.zh.md
Normal file
11
packages/acp/README.zh.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# acp/:Agent Client Protocol 自动化
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,而非展示层或人机交互层。
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接拥有的清理。 |
|
||||
|
||||
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以驱动同一服务器契约。
|
||||
6
packages/acp/acp/README.i18n.yaml
Normal file
6
packages/acp/acp/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88
|
||||
README.zh.md: f8abe9e45a5efffa436513f7d4a931c69c624b84
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-acp
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md).
|
||||
|
||||
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the web and TUI modules.
|
||||
|
||||
79
packages/acp/acp/README.zh.md
Normal file
79
packages/acp/acp/README.zh.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# @deepseek-ai/dsh-acp
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、通过策略解决一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
|
||||
|
||||
此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、mode、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与人类问题属于 Web 和 TUI 模块。
|
||||
|
||||
## 插件
|
||||
|
||||
`apply(ctx, config)` 在 stdin/stdout 上打开 `AgentSideConnection` 并驱动 `ctx.agents`。Stdout 专用于协议帧。
|
||||
|
||||
| 配置 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `provider` | 无 | 每个已创建 agent 的初始提供方路由。 |
|
||||
| `model` | 无 | 每个已创建 agent 的初始模型。 |
|
||||
|
||||
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。
|
||||
|
||||
## 协议契约
|
||||
|
||||
| 方法 | 行为 |
|
||||
|---|---|
|
||||
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 使用绝对主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 连接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并从该请求拥有的持久 `turn/end` 结算。 |
|
||||
| `session/cancel` | 仅取消被定址的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id 的桥接层所有批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
|
||||
一个连接可以拥有多个会话。桥接层使用带品牌的 session id 为记录建键,并在路由事件或权限请求前检查精确的 agent 标识。每个会话都有独立的提示词槽位、workspace、取消路径和 disposer。
|
||||
|
||||
已提交消息输出有意以逐 token 延迟换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
|
||||
|
||||
## 生命周期
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行释放所有已拥有的 agent handle,并等待它们的循环/会话清理完成。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
## 运行
|
||||
|
||||
`pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。父 harness 可以通过 [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md) spawn 它;其他 ACP 客户端只需上述核心方法。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 提示词文本
|
||||
|
||||
#### 模型所见内容
|
||||
|
||||
`session/prompt` 文本块会原样连接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
提示词 token 取决于数据,并保留在该会话的历史中直到压缩。并发 ACP 会话保留独立上下文。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新用户消息位于可复用请求前缀之后,不会使先前缓存条目失效。
|
||||
|
||||
### 权限决策
|
||||
|
||||
#### 模型所见内容
|
||||
|
||||
没有直接内容。拥有该决策的工具通过常规工具结果路径记录允许、拒绝、取消或不可用结果。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有拥有该决策的工具结果会贡献 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
通过所属工具结果仅追加。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- **仅新会话**:不支持加载、列出、恢复、删除和 fork。
|
||||
- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会被展平为文本引用,而不是已获取内容。
|
||||
- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不上线。
|
||||
- **连接拥有的生命期**:一个连接会释放其所有会话;尚未实现每会话关闭。
|
||||
6
packages/bash/README.i18n.yaml
Normal file
6
packages/bash/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 08b36270800cdd79c82d6781bbfb2e12e2dc2060
|
||||
README.zh.md: a98506a6cdf41e5b298b40e1b8e1faf0c4c917d2
|
||||
@@ -1,5 +1,7 @@
|
||||
# bash/ — bash capability family
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The canonical three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, concrete implementations, and the model-facing tool that consumes it. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|
||||
14
packages/bash/README.zh.md
Normal file
14
packages/bash/README.zh.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# bash/:bash 能力家族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品** 包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇) | `ctx.bash` |
|
||||
| `bash-local/` | 本地子进程 `BashExecutor` 实现 | (注册 `ctx.bash`) |
|
||||
| `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) |
|
||||
| `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) |
|
||||
|
||||
接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器配置项;受限实现还需选择一个 `ctx.sandbox` 提供方配置项(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。
|
||||
6
packages/bash/bash-local/README.i18n.yaml
Normal file
6
packages/bash/bash-local/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 1668f33e8acf6d749d4d3753478c12d48a19ac3c
|
||||
README.zh.md: 0e0a4ad41b532e39f6f2470aa981a08b6d6230c1
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-bash-local
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
|
||||
|
||||
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
|
||||
|
||||
49
packages/bash/bash-local/README.zh.md
Normal file
49
packages/bash/bash-local/README.zh.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# @deepseek-ai/dsh-bash-local
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@deepseek-ai/dsh-bash` 执行器 seam 的本地子进程实现:`LocalBashExecutor` 每次调用都会在独立进程组中 spawn `bash -c <command>`,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。
|
||||
|
||||
包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`;子进程管道细节保留在该实现包内部。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
cwd: /path/to/workspace # default: process.cwd()
|
||||
timeoutMs: 120000 # default foreground timeout
|
||||
maxTimeoutMs: 600000 # cap for per-call overrides
|
||||
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
|
||||
maxSpillBytes: 67108864 # per-stream full-output spill cap
|
||||
graceMs: 3000 # kill escalation and post-exit pipe-drain grace
|
||||
```
|
||||
|
||||
## 行为(以及设计来源)
|
||||
|
||||
设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下:
|
||||
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/run.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。
|
||||
- **使用逐步升级终止整个进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 `graceMs` 宽限期后再发送 SIGKILL(默认 3 秒,沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。主 shell 退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止命令结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
|
||||
- **保留尾部的截断 + 有界 spill 文件**:输出超过 `maxOutputBytes` 后,内存中保留尾部(错误/结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到临时文件,并在可用时报告该路径。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台任务仍使用 `maxOutputBytes`。某个流大于 `maxSpillBytes` 时,会丢弃已不完整的 spill,仅返回带截断标记的尾部。如果最终关闭 spill 时报告延迟写回失败,执行器同样不会公布路径,以免声称存在不完整的文件。
|
||||
- **适合模型的环境变量 + 凭证清理**:以 `process.env` 为基础,移除形似凭证的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中的 `DSH_*` 名称,再设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果。spec 的普通 `env` 在清理后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止遗留嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 使用全流字节偏移量进行增量读取;dispose 会终止每个运行中的进程并等待其退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-bash` 间接影响;该工具会渲染此执行器有界的 stdout/stderr 尾部、后台进程增量、spill 文件路径与基础设施失败。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;请求前缀变更由具名消费方负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。
|
||||
- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。
|
||||
- **仅支持 POSIX**:`bash` 二进制、独立进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。
|
||||
- **凭证清理依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
|
||||
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
|
||||
|
||||
原始进程处理位于 `src/run.ts`;`src/index.ts` 负责服务接线。
|
||||
6
packages/bash/bash-sandbox/README.i18n.yaml
Normal file
6
packages/bash/bash-sandbox/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: ca77a9c626784b29145712535d69de4afbd3a697
|
||||
README.zh.md: c1a65ead539ef3930d70d27f2b176a5346daded3
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-bash-sandbox
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
|
||||
|
||||
90
packages/bash/bash-sandbox/README.zh.md
Normal file
90
packages/bash/bash-sandbox/README.zh.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# @deepseek-ai/dsh-bash-sandbox
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
消费 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 的沙箱实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);后者拥有默认模式 + 工作区根目录,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。
|
||||
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用(必须快速失败并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默无约束运行),属于提供方职责;本包只拥有 bash 侧。
|
||||
|
||||
| 模式 | 文件影响 |
|
||||
|---|---|
|
||||
| `read-only`(默认) | 任何位置都不可写(在 `/dev` 中只有 `/dev/null` 节点可写,因此 `>/dev/null` 仍可正常工作) |
|
||||
| `workspace-write` | 只能写入 `workspaceRoot` + `/tmp`(在 bwrap 下为临时目录,在 Landlock 下为宿主 `/tmp`,在 Seatbelt 下为 `/private/tmp` 加每用户临时目录) |
|
||||
| `danger-full-access` | 不作限制;绝不咨询提供方。前台结果携带 `sandbox: { mode, denied: false }`;后台进程句柄不携带沙箱事实。 |
|
||||
|
||||
语义:
|
||||
|
||||
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
|
||||
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,bash 产生方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent 调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。
|
||||
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
|
||||
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
|
||||
|
||||
seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它驱动本包遵守的覆盖。
|
||||
|
||||
```yaml
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: read-only
|
||||
workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
```
|
||||
|
||||
无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,在真实世界验证,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 间接的 Bash 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布一个执行限制的 `sandboxMode`,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
在 `bash` 可见的请求上增加少量固定 schema;模式切换不增加上下文 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
执行器持续公布相同沙箱能力时,前缀保持稳定。更改这些能力会改变 `bash` schema,可能使从该定义起的复用失效;每会话模式切换不会导致失效。
|
||||
|
||||
### 间接的 Bash 工具结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
在普通有界输出之后,被拒绝的调用会精确追加 `[sandbox: file access denied under <mode> mode]`。当升权可用时,接下来精确追加 `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`。已结算的后台 runner 失败则追加 `[sandbox: the sandbox runner itself failed under <mode> mode — the command did not run; this is a sandbox problem, not a command failure]`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
|
||||
|
||||
### 间接的 Bash 工具错误
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误;它由 `dsh-sandbox` 持有](../../sandbox/sandbox/README.md#confinement-error-indirectly)。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
该次调用可见的是有条件错误文本,并保留在历史记录中直到压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
|
||||
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但匹配的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
|
||||
- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
|
||||
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。
|
||||
6
packages/bash/bash/README.i18n.yaml
Normal file
6
packages/bash/bash/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: b4ee66a1fa2696254a1f2f411b7db5d3190f8370
|
||||
README.zh.md: 151d4bd7ab257234584b9008c96e6356d7e39351
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-bash
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run foreground commands and start background processes — without saying HOW. Task ids, ownership, collection, cancellation, and notices belong to the generic `ctx.tasks` runtime.
|
||||
|
||||
This package is the interface quarter of the bash capability, split so each concern can evolve (and be swapped) independently:
|
||||
|
||||
49
packages/bash/bash/README.zh.md
Normal file
49
packages/bash/bash/README.zh.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# @deepseek-ai/dsh-bash
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
|
||||
|
||||
本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-bash`(本包) | 接口:抽象服务 + 词汇类型 |
|
||||
| `@deepseek-ai/dsh-bash-local` | 实现:本地子进程 |
|
||||
| `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 |
|
||||
| `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema |
|
||||
|
||||
该拆分与 LLM seam(`LlmService`/`LlmAdapter`)及 agent 工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。
|
||||
|
||||
## 服务 API(`ctx.bash`)
|
||||
|
||||
| 成员 | 语义 |
|
||||
|---|---|
|
||||
| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止终止都会 resolve 为描述性 `BashRunResult`。 |
|
||||
| `start(spec)` | 后台执行。立即返回不含任务语义的 `BashProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.tasks`。 |
|
||||
| `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 |
|
||||
| `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 |
|
||||
| `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 |
|
||||
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose 必须终止每个运行中的进程并等待其退出,详见 HMR 安全测试。
|
||||
|
||||
## 词汇
|
||||
|
||||
`BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,拒绝普通 `env` 中的这些名称,再合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-bash` 间接影响;该工具会将执行器输出与沙箱事实转为指引和保留的工具结果 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;请求前缀变更由具名消费方负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有交互式输入词汇**:`stdin` 只会在 spawn 时写入一次并关闭;seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。
|
||||
- **前台超时始终由执行器拥有**:seam 上的调用方拥有 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。
|
||||
6
packages/bash/tool-bash/README.i18n.yaml
Normal file
6
packages/bash/tool-bash/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46
|
||||
README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-tool-bash
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `bash` tool registered over the `ctx.bash` executor seam. Foreground execution stays behind that seam; a background process handle is registered with the generic `ctx.tasks` runtime and controlled through `task_output`, `task_list`, and `task_kill` from `@deepseek-ai/dsh-tool-tasks`.
|
||||
|
||||
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
|
||||
158
packages/bash/tool-bash/README.zh.md
Normal file
158
packages/bash/tool-bash/README.zh.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# @deepseek-ai/dsh-tool-bash
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output`、`task_list` 和 `task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。
|
||||
|
||||
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`);在 `ctx.bash` 可用之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt']`)。
|
||||
|
||||
包(package)根只公开 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍是实现细节,由同包测试覆盖。
|
||||
|
||||
插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
|
||||
|
||||
## 工具
|
||||
|
||||
### `bash`
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `command` | string(必填) | 通过 `bash -c` 运行。调用之间不保留状态;请使用 `workdir`,不要使用 `cd`。 |
|
||||
| `description` | string(必填) | 用一行主动语态概述命令(5~10 个词),仅用于 UI/日志显示,不影响执行。 |
|
||||
| `timeoutMs` | number | 以毫秒为单位覆盖超时时间。执行器会应用其配置的默认值和上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认为调用方 agent(智能体)会话 cwd 的文件系统标识(`session.header.cwd`),使每个会话都在自己的工作区中运行;相对 `workdir` 也以同一标识为基准解析。 |
|
||||
| `run_in_background` | boolean | 立即返回 task id;不应用超时。 |
|
||||
| `sandbox_permissions` | string enum | 仅当已挂载的执行器启用沙箱时才会公开(`ctx.bash.sandboxMode` 报告一个具有限制作用的默认值):被拒命令所需的更宽模式,取自封闭的目标词汇 `workspace-write`/`danger-full-access`(绝不能缩减为执行器默认值;有效模式按会话确定,执行时会基于它检查是否严格拓宽,未拓宽的请求直接失败,不会向任何人发起提示)。 |
|
||||
| `justification` | string | 必须与 `sandbox_permissions` 一同提供(缺少任一项都会产生验证错误):用一句话向用户解释此命令为何需要这项更宽权限。 |
|
||||
|
||||
执行前,`command`、`workdir` 和 `timeoutMs` 会通过 `ctx.bash.resolve()` 依据执行器配置默认值完成解析,因此执行器 seam(`BashExecSpec`)收到显式的 `workdir`/`timeoutMs` 值。工具层会根据调用方 agent 的 `session.header.cwd` 应用工作目录默认值,然后才调用 `resolve()`:由于 N 个会话共享一个执行器,逐会话 cwd 必须来自 `exec.agent`;只有无法取得会话 cwd 时,执行器才回退到自身配置/`process.cwd()`。存在沙箱策略时,工具会复用已经规范化的 `workspaceRoot` 作为工作目录基准,防止限制逻辑与进程启动过程对同一个会话路径拼写产生不同解析结果。
|
||||
|
||||
### 托管 shell 环境
|
||||
|
||||
每次模型发起的前台或后台 bash 调用都会收到新收集的一组可信 `DSH_*` 环境变量。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 Harness home 绝对路径(依次采用 `dshHome` 配置、环境中的 `$DSH_HOME`、`~/.dsh`),`DSH_SHELL=1` 则标识受托管的子进程。Agent 调用还会收到 `DSH_SESSION_ID=agent.session.header.id`;当活跃的持久化 seam 找到 JSONL 产物时,也会收到 `DSH_SESSION_JSONL=<absolute target path>`。JSONL 路径只是位置提示:首次 flush 前它可能尚不存在,也可能不包含当前缓冲的轮次,并且它不是授权凭据。
|
||||
|
||||
`ctx.bashEnv` 持有收集过程。其他插件可以注册具有 effect 作用域的贡献方,提供稳定名称、已声明的键/说明以及 `resolve(execution: ToolExecution)`;重复持有或运行时返回未声明的键会快速失败,而 `list()` 无需执行提供方即可列举声明。Harness 内置项保留 `DSH_HOME`、`DSH_SHELL` 和 `DSH_SESSION_ID`;tool-bash 的持久化转换器持有 `DSH_SESSION_JSONL`,其值来自后端无关的 `sessionPersistence.locate()` seam。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.bashEnv.register({
|
||||
name: 'deployment-region',
|
||||
variables: { DSH_DEPLOYMENT_REGION: { description: 'Current deployment region.' } },
|
||||
resolve: execution => execution.agent === undefined ? {} : { DSH_DEPLOYMENT_REGION: 'cn-north' },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecRequest.dshEnv` 通道传递。本地执行器会先删除继承的所有 `DSH_*`,再合并该快照,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份。它绝不会修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
|
||||
|
||||
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`。
|
||||
|
||||
已完成前台进程的规范成功值为 `{ kind: 'foreground', ...BashRunResult }`,已发布任务则为 `{ kind: 'background', taskId }`。Native renderer 保留上述文本,包括精确的 `started background task <id>`;程序化消费方使用带类型字段,无需解析这些字符串。执行器的流上限仍是 `BashRunResult` 的采集限制,并携带其 spill 路径。
|
||||
|
||||
当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.tasks.start()`,把调用方 agent 注册为持有者,并将返回的 `BashProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时持有 id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。
|
||||
|
||||
## UI 展示
|
||||
|
||||
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
|
||||
|
||||
## 工具仅使用具名参数构建请求
|
||||
|
||||
`BashExecRequest` seam 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
|
||||
## 权限与升权
|
||||
|
||||
除非启用沙箱的执行器([`dsh-bash-sandbox`](../bash-sandbox/))限制命令,否则命令以执行器的完整权限运行。仅拒绝型沙箱会把拒绝作为结果事实报告,并在此渲染为拒绝标记;逐调用的允许/拒绝/询问策略由 `tools/pre-execute` waterfall(瀑布式事件)负责(参见 docs/architecture.md)。
|
||||
|
||||
需要升权的 bash 调用会在执行前解析 `ctx.approval`。`allowed-once` 只对该次调用应用请求模式;审批被拒、取消、不可用或缺少审批上下文时,命令完全不会执行,并返回不同的错误。发生真实拒绝后,模型可以在同一轮次中使用满足需要的最窄模式和理由重试同一命令一次;审批提示本身就是征求同意的步骤。升权绝不能预先推测,禁用或拒绝审批即为最终结果。其理由由 [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 持有。
|
||||
|
||||
## 逐会话模式切换
|
||||
|
||||
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。提示词和切换通知均不公布当前常驻模式;拒绝结果会在边界相关时报告有效模式。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 系统提示词
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
此插件注册作用域内的每个请求都包含下方 bash 指引。启用沙箱的执行器不会添加模式声明或切换通知。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。
|
||||
|
||||
##### Bash 指引
|
||||
|
||||
```markdown
|
||||
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
插件活跃期间,每个请求都会产生少量固定输入开销,不受沙箱模式或模式切换影响。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要注册作用域和提示词文本不变,前缀即可稳定复用。插件激活或 dispose 可能从此提示词段落开始使复用失效;沙箱模式切换不会。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型会看到生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。仅当此生产方启用 `run_in_background` 时,该字段才会出现;仅当已挂载执行器声明支持沙箱时,`sandbox_permissions` 和 `justification` 才会出现。Agent 作用域的工具限制可以移除该 agent 的定义。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具可见的每个请求都会产生固定 schema 开销;沙箱支持会增加升权字段及其条件说明段落。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要可见性、后台支持和执行器沙箱功能保持不变,前缀即可稳定复用。限制、配置或执行器发生变化时,可能从首个变化的工具定义开始使复用失效。
|
||||
|
||||
### 前台结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr]` 和 stderr 尾部。没有输出时,它会精确输出 `(no output)`。条件行精确为 `[output truncated; full output: <path-or-(unavailable)>]`、`[sandbox: file access denied under <mode> mode]`、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 和 `[exit code: <exitCode>]`;沙箱升权与 runner 故障行原文列于 [`dsh-bash-sandbox`](../bash-sandbox/README.md)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
调用前结果 token 为零。每条流的输出有界,每个已输出行则会保留在历史中,直至压缩(compaction)。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 后台任务上下文与结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
启动会精确返回 `started background task <taskId>`。此生产方会向通用任务运行时提供增量进程输出、可选的 `[some output was dropped from memory; full output: <paths-or-(unavailable)>]`、沙箱事实,以及 `exit code: <exitCode>` 或 `signal: <signal>` 等终止详情。[`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) 持有模型可见的状态行、完成通知、列表和取消响应。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
启动确认很短并会保留;收集到的输出依数据而定,并受执行器流缓冲区限制。消费式读取不会重复先前输出。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 工具错误
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
验证和策略失败统一为 `Error: <message>`。此包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`background execution is disabled for this bash tool`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、`sandbox escalation to "<mode>" is not strictly wider than this call's current "<mode>" mode`、审批不可用/拒绝/取消变体,以及 `command aborted`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有失败调用会增加这些保留 token;升权被拒时命令不会运行,因此不会添加命令输出。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill;这是仅影响展示的已知残留问题。
|
||||
- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。
|
||||
- **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。
|
||||
@@ -60,6 +60,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
|
||||
@@ -535,7 +535,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// The caller owns cancellation until TaskService commits detached ownership.
|
||||
// The caller owns cancellation until ctx.tasks commits detached ownership.
|
||||
if (exec.signal.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
|
||||
@@ -8,7 +8,7 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
@@ -27,7 +27,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot, compression: 'none' })
|
||||
}
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
|
||||
@@ -169,7 +169,7 @@ describe('bash tool through the agent loop', () => {
|
||||
})
|
||||
|
||||
it('background: start ack → completion notice as user/message → task_output collects it', async () => {
|
||||
// The task id is deterministic (a fresh TaskService counts per kind from 1),
|
||||
// The task id is deterministic (a fresh LocalTaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
|
||||
|
||||
@@ -12,7 +12,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
||||
@@ -44,7 +44,7 @@ async function setupWithTasks() {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
|
||||
@@ -180,7 +180,7 @@ async function setupSandboxed(withApproval = false) {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(RecordingSandboxExecutor)
|
||||
@@ -472,7 +472,7 @@ describe('background execution through the task runtime', () => {
|
||||
})
|
||||
|
||||
it('fails loud when the task runtime is not loaded', async () => {
|
||||
const ctx = await setup() // no TaskService / ToolTasks
|
||||
const ctx = await setup() // no LocalTaskService / ToolTasks
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
@@ -483,7 +483,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
@@ -511,7 +511,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
@@ -1073,7 +1073,7 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: join(spillDir, 'jsonl') })
|
||||
}
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(RecordingBashExecutor)
|
||||
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
|
||||
|
||||
6
packages/client/connection/README.i18n.yaml
Normal file
6
packages/client/connection/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 80228a180faba0c556ff720e999b29b5bb1635b6
|
||||
README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-client-connection
|
||||
|
||||
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 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.
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
22
packages/client/connection/README.zh.md
Normal file
22
packages/client/connection/README.zh.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-client-connection
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。
|
||||
- **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。
|
||||
@@ -10,6 +10,7 @@ export type {
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
|
||||
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -124,6 +124,49 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
|
||||
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
|
||||
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
|
||||
// Mode acceptance surface (parent code row + nested native-identical rows,
|
||||
// including an isError sub-call and a bash sub-call that must hit the same
|
||||
// keyed registration a top-level bash row uses).
|
||||
{
|
||||
const turn = 64
|
||||
const callId = `fx-call-${turn}`
|
||||
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
|
||||
+ 'const demo = await tools.read({ path: "notes/demo.txt" })\n'
|
||||
+ 'await tools.read({ 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: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:run_code 样本。`), source: { kind: 'user' } } })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
type: 'assistant/message', surfaceOp: 'append',
|
||||
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
|
||||
})
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } })
|
||||
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
|
||||
push({
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
|
||||
},
|
||||
})
|
||||
}
|
||||
dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
|
||||
dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
|
||||
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
|
||||
push({
|
||||
type: 'tool/result', surfaceOp: 'append',
|
||||
data: { turn, step: 0, callId, content: text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), isError: false },
|
||||
})
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
return events as unknown as SessionEvent[]
|
||||
}
|
||||
|
||||
@@ -304,10 +347,11 @@ class FxInbox<F> implements StreamConn<F> {
|
||||
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
|
||||
*/
|
||||
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
const sessions: SessionSummary[] = options.empty ? [] : [
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
||||
]
|
||||
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
||||
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
|
||||
@@ -388,6 +432,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
}
|
||||
|
||||
const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id)
|
||||
/** Shared session guard for sessionId-addressed catalog routes: the error
|
||||
* response when the session is unknown, undefined when it exists. */
|
||||
const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<never>> | undefined => {
|
||||
if (summaryOf(request.payload.sessionId) !== undefined) return undefined
|
||||
return err<{ sessionId: SessionId }, never>(request, {
|
||||
code: 'session-not-found',
|
||||
message: `no session ${request.payload.sessionId}`,
|
||||
details: { sessionId: request.payload.sessionId },
|
||||
})
|
||||
}
|
||||
const setRunning = (id: SessionId, running: boolean): void => {
|
||||
const summary = summaryOf(id)
|
||||
if (summary === undefined || summary.running === running) return
|
||||
@@ -543,13 +597,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
}
|
||||
}
|
||||
const created: SessionSummary = {
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd,
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
||||
}
|
||||
sessions.push(created)
|
||||
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId, cwd })
|
||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||
emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd })
|
||||
}
|
||||
if (workspace !== undefined && options.failWorkspaceAttach) {
|
||||
emitSession()
|
||||
@@ -619,6 +674,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
})
|
||||
}
|
||||
summary.updatedAt = Date.now()
|
||||
// First accepted prompt appends events: the summary stops being blank.
|
||||
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.
|
||||
@@ -731,6 +788,52 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return ok(request, { workspace: { ...workspace } })
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
// The catalog mirrors one session's effective view (every fixture
|
||||
// session has an agent, like the real host).
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
commands: [
|
||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||
{ name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '<objective>' } },
|
||||
],
|
||||
})
|
||||
},
|
||||
execute: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const line = request.payload.line.trim()
|
||||
const match = /^\/(\S+)(?:\s+(.*))?$/.exec(line)
|
||||
const name = match?.[1]
|
||||
if (name === 'compact' || name === 'echo') {
|
||||
return ok(request, {
|
||||
matched: true as const,
|
||||
result: { kind: 'success' as const, text: name === 'echo' ? (match?.[2] ?? '') : 'fixture:已压缩(假动作)' },
|
||||
})
|
||||
}
|
||||
if (name === 'goal-fixture') {
|
||||
return ok(request, {
|
||||
matched: true as const,
|
||||
result: { kind: 'success' as const, text: `fixture:goal 已设置(${request.payload.sessionId})` },
|
||||
})
|
||||
}
|
||||
return ok(request, { matched: false as const })
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
skills: [
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
const conn = new FxInbox<MuxFrame>()
|
||||
@@ -850,6 +953,10 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(request)
|
||||
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
// The in-memory execute never blocks, so a never-aborting signal is faithful here.
|
||||
case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export type {
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
|
||||
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
HostFrame, IApiClient, ModelTarget, MuxFrame, RpcRequest, RpcResponse, SessionId, SessionModels,
|
||||
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -100,6 +101,24 @@ export class FakeApiClient implements IApiClient {
|
||||
}))),
|
||||
}
|
||||
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program catalogs and skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
92
packages/client/connection/tests/fixture-commands.spec.ts
Normal file
92
packages/client/connection/tests/fixture-commands.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Fixture commands/skills domains: contract-shape conformance for the two
|
||||
* domains added to ApiProxy — rpcId echo, session-addressed catalogs, execute
|
||||
* parse/dispatch, skill.list session resolution, and the FixtureApiClient
|
||||
* dispatch rows.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import type { RpcRequest } from '../src/client/api.ts'
|
||||
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
let reqCount = 0
|
||||
const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`), payload })
|
||||
const signal = new AbortController().signal
|
||||
|
||||
describe('createFixtureApi commands/skills', () => {
|
||||
it('serves the addressed session catalog with rpcId echo', async () => {
|
||||
const api = createFixtureApi()
|
||||
const request = req({ sessionId: sid('fx-alpha') })
|
||||
const response = await api.commands.list(request)
|
||||
expect(response.rpcId).toBe(request.rpcId)
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
const commands = response.result.value.commands
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture'])
|
||||
// input hint rides only the commands declaring it.
|
||||
const echo = commands.find(c => c.name === 'echo')
|
||||
expect(echo?.input?.hint).toBeTruthy()
|
||||
expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a catalog request for an unknown session', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.commands.list(req({ sessionId: sid('fx-nope') }))
|
||||
expect(response.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('executes a known command line and reports matched with a result', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/echo hello world' }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
expect(response.result.value.matched).toBe(true)
|
||||
expect(response.result.value.result).toEqual({ kind: 'success', text: 'hello world' })
|
||||
})
|
||||
|
||||
it('addresses execute to the session (result text carries the id)', async () => {
|
||||
const api = createFixtureApi()
|
||||
const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
|
||||
if (!hit.result.ok) throw new Error('execute failed')
|
||||
expect(hit.result.value.matched).toBe(true)
|
||||
expect(hit.result.value.result?.text).toContain('fx-alpha')
|
||||
|
||||
const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('falls to matched:false on unknown names and non-command lines', async () => {
|
||||
const api = createFixtureApi()
|
||||
for (const line of ['/nope', 'plain text', '/']) {
|
||||
const response = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line }), signal)
|
||||
if (!response.result.ok) throw new Error('execute failed')
|
||||
expect(response.result.value.matched).toBe(false)
|
||||
expect(response.result.value.result).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.skills.list(req({ sessionId: sid('fx-alpha') }))
|
||||
if (!response.result.ok) throw new Error('skill list failed')
|
||||
expect(response.result.value.skills[0]?.name).toBe('fixture-demo')
|
||||
|
||||
const missingSession = await api.skills.list(req({ sessionId: sid('fx-nope') }))
|
||||
expect(missingSession.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('FixtureApiClient command/skill dispatch', () => {
|
||||
it('routes the three method keys through the in-memory dispatch table', async () => {
|
||||
const client = new FixtureApiClient()
|
||||
const list = await client.commands.list({ sessionId: sid('fx-alpha') })
|
||||
if (!list.result.ok) throw new Error('command.list failed')
|
||||
expect(list.result.value.commands.length).toBeGreaterThan(0)
|
||||
const executed = await client.commands.execute({ sessionId: sid('fx-alpha'), line: '/compact' })
|
||||
if (!executed.result.ok) throw new Error('command.execute failed')
|
||||
expect(executed.result.value.matched).toBe(true)
|
||||
const skills = await client.skills.list({ sessionId: sid('fx-alpha') })
|
||||
if (!skills.result.ok) throw new Error('skill.list failed')
|
||||
expect(skills.result.value.skills.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -123,7 +123,7 @@ describe('createFixtureApi', () => {
|
||||
await consuming
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const createdId = created.result.value.sessionId
|
||||
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, cwd: '/tmp/fixture' }])
|
||||
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }])
|
||||
const list = await api.sessions.list(req({}))
|
||||
if (!list.result.ok) throw new Error('list failed')
|
||||
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
|
||||
@@ -420,7 +420,7 @@ describe('createFixtureApi', () => {
|
||||
await consuming
|
||||
// The session lands with the workspace's path as cwd, and the account
|
||||
// write pushes the fresh workspace snapshot after session-added.
|
||||
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, cwd: '/tmp/fixture' })
|
||||
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' })
|
||||
expect(seen[1]).toMatchObject({
|
||||
type: 'host/workspace-changed',
|
||||
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
|
||||
@@ -449,7 +449,7 @@ describe('createFixtureApi', () => {
|
||||
expect(frames[0]).toMatchObject({
|
||||
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
|
||||
})
|
||||
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, cwd: made.result.value.workspace.path })
|
||||
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path })
|
||||
|
||||
const retried = await api.sessions.create(req({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
|
||||
6
packages/client/hmr/README.i18n.yaml
Normal file
6
packages/client/hmr/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
|
||||
README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-client-hmr
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
21
packages/client/hmr/README.zh.md
Normal file
21
packages/client/hmr/README.zh.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-client-hmr
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。
|
||||
|
||||
浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber 之前执行:只释放 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 通道。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。重载驱动器属于浏览器侧机制;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **重载有意保持粗粒度**:会创建全新的 fiber 和组件;重载插件中的 React 状态会丢失,数据层(connection/runtime fiber、Session 对象)不受影响。react-refresh 级状态保留与「重新执行组合包会重新运行 factory」冲突,因此有意排除。
|
||||
- **失败时不回滚**:失败的重载会使配置项处于 FAILED 状态,并在 loader 状态投影中高声报告;自动恢复先前组合包会等到实际需要出现后再实现。
|
||||
- **重建帧不会刷新图 rev**:陈旧 rev 无害(组合包端点以 no-cache 提供内容);rev 刷新会随重新连接握手机制落地。
|
||||
@@ -1,16 +0,0 @@
|
||||
# @deepseek-ai/dsh-client-i18n
|
||||
|
||||
i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the i18n registry serves browser UI copy; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks.
|
||||
- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity.
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Browser-side locale registry. Bound translation functions retain stable
|
||||
* identity for injected consumers.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
// Snapshot stores are framework-neutral; React consumers bind hooks at their
|
||||
// rendering boundary.
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { en } from '../locales/en.ts'
|
||||
import { zh } from '../locales/zh.ts'
|
||||
|
||||
/** Translate a key with optional params. */
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
|
||||
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
||||
export type LocaleDict = Record<string, string>
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
i18n: I18nService
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback locale consulted after the active locale misses. */
|
||||
export const FALLBACK_LOCALE = 'zh'
|
||||
|
||||
/** Shared namespace for shell-level texts. */
|
||||
export const COMMON_NS = 'common'
|
||||
|
||||
/**
|
||||
* Dictionary registry plus locale switch. Lookup chain per key: active locale
|
||||
* -> zh fallback -> the key itself (missing text stays visible, fail loud in
|
||||
* the UI rather than blank).
|
||||
*/
|
||||
export class I18nService {
|
||||
private dicts = new Map<string, Map<string, LocaleDict>>()
|
||||
private bound = new Map<string, Translate>()
|
||||
private localeStore = createSnapshotStore<string>(FALLBACK_LOCALE)
|
||||
|
||||
/**
|
||||
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
|
||||
* throws (single occupant; a namespace's texts have one owner).
|
||||
* @param ns - namespace.
|
||||
* @param locale - locale tag (zh/en to start).
|
||||
* @param dict - dictionary.
|
||||
* @returns disposer (idempotent).
|
||||
*/
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void {
|
||||
let locales = this.dicts.get(ns)
|
||||
if (!locales) {
|
||||
locales = new Map()
|
||||
this.dicts.set(ns, locales)
|
||||
}
|
||||
if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`)
|
||||
locales.set(locale, dict)
|
||||
return () => {
|
||||
const owner = this.dicts.get(ns)
|
||||
if (owner?.get(locale) === dict) owner.delete(locale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a namespace to a translate function. The returned reference is
|
||||
* stable per namespace (repeat binds return the same function), so it can
|
||||
* ride inject surfaces without breaking memoization.
|
||||
* @param ns - namespace.
|
||||
* @returns the translate function (reads the locale store at call time).
|
||||
*/
|
||||
bind(ns: string): Translate {
|
||||
let t = this.bound.get(ns)
|
||||
if (!t) {
|
||||
t = (key, params) => this.translate(ns, key, params)
|
||||
this.bound.set(ns, t)
|
||||
return t
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/** Active locale store (switching re-renders the tree; low frequency). */
|
||||
get locale(): SnapshotStore<string> {
|
||||
return this.localeStore
|
||||
}
|
||||
|
||||
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
const locales = this.dicts.get(ns)
|
||||
const template = locales?.get(this.localeStore.getSnapshot())?.[key]
|
||||
?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
?? key
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match)
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services (none; the loader passes the export surface as an object plugin). */
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the i18n service with base dictionaries.
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const i18n = new I18nService()
|
||||
i18n.register(COMMON_NS, 'zh', zh)
|
||||
i18n.register(COMMON_NS, 'en', en)
|
||||
ctx.provide('i18n', i18n)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
|
||||
describe('I18nService', () => {
|
||||
it('translates from the active locale with zh fallback then key passthrough', () => {
|
||||
const i18n = new I18nService()
|
||||
i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
|
||||
i18n.register('ns', 'en', { hello: 'Hello' })
|
||||
const t = i18n.bind('ns')
|
||||
expect(i18n.locale.getSnapshot()).toBe('zh')
|
||||
expect(t('hello')).toBe('你好')
|
||||
i18n.locale.set('en')
|
||||
expect(t('hello')).toBe('Hello')
|
||||
expect(t('onlyZh')).toBe('仅中文')
|
||||
expect(t('missing.key')).toBe('missing.key')
|
||||
})
|
||||
|
||||
it('interpolates {name} params and leaves unknown placeholders intact', () => {
|
||||
const i18n = new I18nService()
|
||||
i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
|
||||
const t = i18n.bind('ns')
|
||||
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
|
||||
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
|
||||
expect(t('greet')).toBe('你好,{name}!第 {n} 次')
|
||||
})
|
||||
|
||||
it('bind returns a stable reference per namespace', () => {
|
||||
const i18n = new I18nService()
|
||||
expect(i18n.bind('a')).toBe(i18n.bind('a'))
|
||||
expect(i18n.bind('a')).not.toBe(i18n.bind('b'))
|
||||
})
|
||||
|
||||
it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => {
|
||||
const i18n = new I18nService()
|
||||
const dispose = i18n.register('ns', 'zh', { k: 'v1' })
|
||||
expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
|
||||
dispose()
|
||||
dispose()
|
||||
const t = i18n.bind('ns')
|
||||
expect(t('k')).toBe('k')
|
||||
i18n.register('ns', 'zh', { k: 'v2' })
|
||||
expect(t('k')).toBe('v2')
|
||||
})
|
||||
|
||||
it('locale store is subscribable (snapshot store contract)', () => {
|
||||
const i18n = new I18nService()
|
||||
let notified = 0
|
||||
i18n.locale.subscribe(() => { notified += 1 })
|
||||
i18n.locale.set('en')
|
||||
expect(i18n.locale.getSnapshot()).toBe('en')
|
||||
expect(notified).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n'
|
||||
import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(I18nInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', () => {
|
||||
nodeApply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
|
||||
it('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => {
|
||||
expect(inject).toEqual([])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
const i18n = ctx.get('i18n')
|
||||
expect(i18n).toBeInstanceOf(I18nService)
|
||||
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
|
||||
expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
|
||||
expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
6
packages/client/locale/README.i18n.yaml
Normal file
6
packages/client/locale/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 9015af2b44a33771b06863ace139fe97695df616
|
||||
README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba
|
||||
18
packages/client/locale/README.md
Normal file
18
packages/client/locale/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @deepseek-ai/dsh-client-locale
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the locale registry serves browser UI copy; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
|
||||
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.
|
||||
18
packages/client/locale/README.zh.md
Normal file
18
packages/client/locale/README.zh.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @deepseek-ai/dsh-client-locale
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 为键持久化;提供 getter/setter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。locale 注册表为浏览器 UI 文案提供服务;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
|
||||
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。
|
||||
62
packages/client/locale/package.json
Normal file
62
packages/client/locale/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-locale",
|
||||
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
}
|
||||
}
|
||||
47
packages/client/locale/src/client/LanguageRow.module.css
Normal file
47
packages/client/locale/src/client/LanguageRow.module.css
Normal file
@@ -0,0 +1,47 @@
|
||||
/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator;
|
||||
* the section column removes the separator on its last child). */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
68
packages/client/locale/src/client/LanguageRow.tsx
Normal file
68
packages/client/locale/src/client/LanguageRow.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Language preference row registered into the General section item slot
|
||||
* (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale
|
||||
* menu. Registered by this package — the locale feature owns its own
|
||||
* settings surface.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from './settings-contract.ts'
|
||||
import type { createLanguageRowStore } from './settings-store.ts'
|
||||
import css from './LanguageRow.module.css'
|
||||
|
||||
/** Injected business face: namespace-bound translate + the preference write. */
|
||||
export interface LanguageRowInjected {
|
||||
/** Translate a `settings.locale` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
/** Switch the active locale (a registered locale id). */
|
||||
setLocale: (id: string) => void
|
||||
}
|
||||
|
||||
/** Full component props: runtime share + store share + injected face. */
|
||||
export type LanguageRowComponentProps =
|
||||
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
|
||||
|
||||
/**
|
||||
* Render the Language row.
|
||||
* @param props - composed slot props.
|
||||
* @returns the row element tree.
|
||||
*/
|
||||
export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) {
|
||||
const active = useStore(s => s.active)
|
||||
const options = useStore(s => s.options)
|
||||
const [open, setOpen] = useState(false)
|
||||
const activeLabel = options.find(o => o.id === active)?.label ?? active
|
||||
|
||||
return (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('language.title')}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={options.map(o => ({ id: o.id, label: o.label }))}
|
||||
selectedId={active}
|
||||
onSelect={(id) => {
|
||||
setLocale(id)
|
||||
setOpen(false)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(v => !v) }}
|
||||
>
|
||||
{activeLabel}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
248
packages/client/locale/src/client/index.ts
Normal file
248
packages/client/locale/src/client/index.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Browser-side locale registry. Bound translation functions retain stable
|
||||
* identity for injected consumers. The plugin also registers the Language
|
||||
* preference row into the settings General section — the locale feature owns
|
||||
* its own settings surface.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { en } from '../locales/en.ts'
|
||||
import { zh } from '../locales/zh.ts'
|
||||
import type { LanguageRowInjected } from './LanguageRow.tsx'
|
||||
import { LanguageRow } from './LanguageRow.tsx'
|
||||
import { createLanguageRowStore } from './settings-store.ts'
|
||||
|
||||
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
|
||||
|
||||
/** Translate a key with optional params. */
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
|
||||
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
||||
export type LocaleDict = Record<string, string>
|
||||
|
||||
/** Locale identifier: the two shipped locales. */
|
||||
export type LocaleId = 'zh' | 'en'
|
||||
|
||||
/** One selectable locale: id plus its self-described display name. */
|
||||
export interface LocaleDefinition {
|
||||
/** Locale id (persisted; the setLocale argument). */
|
||||
id: LocaleId
|
||||
/** Display name in its own language (中文 / English). */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Immutable locale state published on every change. */
|
||||
export interface LocaleSnapshot {
|
||||
/** Active locale id. */
|
||||
active: LocaleId
|
||||
/** Selectable locales in display order. */
|
||||
locales: readonly LocaleDefinition[]
|
||||
/** Monotonic change counter (registry or active changes). */
|
||||
revision: number
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
locale: LocaleService
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
* Locale state changed (active locale switched or registry updated).
|
||||
* @param snapshot - Current immutable locale snapshot.
|
||||
* @mode emit
|
||||
*/
|
||||
'locale/change'(snapshot: LocaleSnapshot): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback locale consulted after the active locale misses (also the default). */
|
||||
export const FALLBACK_LOCALE: LocaleId = 'zh'
|
||||
|
||||
/** Shared namespace for shell-level texts. */
|
||||
export const COMMON_NS = 'common'
|
||||
|
||||
/** Namespace owning this feature's settings-row copy. */
|
||||
export const SETTINGS_NS = 'settings.locale'
|
||||
|
||||
/** localStorage key holding the persisted locale id. */
|
||||
export const STORAGE_KEY = 'dsh.locale'
|
||||
|
||||
/** The two shipped locales. */
|
||||
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
|
||||
{ id: 'zh', label: '中文' },
|
||||
{ id: 'en', label: 'English' },
|
||||
])
|
||||
|
||||
/**
|
||||
* Dictionary registry plus locale preference. Lookup chain per key: active
|
||||
* locale -> zh fallback -> the key itself (missing text stays visible, fail
|
||||
* loud in the UI rather than blank). Reads go through {@link getLocale};
|
||||
* writes only through {@link setLocale}; continuous sync only through the
|
||||
* `locale/change` event.
|
||||
*/
|
||||
export class LocaleService {
|
||||
private dicts = new Map<string, Map<string, LocaleDict>>()
|
||||
private bound = new Map<string, Translate>()
|
||||
private snapshot: LocaleSnapshot
|
||||
private readonly ctx: Context
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it).
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
this.ctx = ctx
|
||||
this.snapshot = Object.freeze({ active: restorePreference(), locales: LOCALES, revision: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current immutable locale snapshot.
|
||||
* @returns the current snapshot (stable reference until the next change).
|
||||
*/
|
||||
getLocale(): LocaleSnapshot {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active locale — the only preference write entry. Persists the
|
||||
* id and emits `locale/change`.
|
||||
* @param id - a registered locale id; unknown ids throw.
|
||||
*/
|
||||
setLocale(id: string): void {
|
||||
const match = this.snapshot.locales.find(l => l.id === id)
|
||||
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
|
||||
if (this.snapshot.active === match.id) return
|
||||
this.snapshot = Object.freeze({
|
||||
active: match.id,
|
||||
locales: this.snapshot.locales,
|
||||
revision: this.snapshot.revision + 1,
|
||||
})
|
||||
persistPreference(match.id)
|
||||
this.ctx.emit('locale/change', this.snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
|
||||
* throws (single occupant; a namespace's texts have one owner).
|
||||
* @param ns - namespace.
|
||||
* @param locale - locale tag (zh/en to start).
|
||||
* @param dict - dictionary.
|
||||
* @returns disposer (idempotent).
|
||||
*/
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void {
|
||||
let locales = this.dicts.get(ns)
|
||||
if (!locales) {
|
||||
locales = new Map()
|
||||
this.dicts.set(ns, locales)
|
||||
}
|
||||
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
||||
locales.set(locale, dict)
|
||||
return () => {
|
||||
const owner = this.dicts.get(ns)
|
||||
if (owner?.get(locale) === dict) owner.delete(locale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a namespace to a translate function. The returned reference is
|
||||
* stable per namespace (repeat binds return the same function), so it can
|
||||
* ride inject surfaces without breaking memoization.
|
||||
* @param ns - namespace.
|
||||
* @returns the translate function (reads the active locale at call time).
|
||||
*/
|
||||
bind(ns: string): Translate {
|
||||
let t = this.bound.get(ns)
|
||||
if (!t) {
|
||||
t = (key, params) => this.translate(ns, key, params)
|
||||
this.bound.set(ns, t)
|
||||
return t
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
const locales = this.dicts.get(ns)
|
||||
const template = locales?.get(this.snapshot.active)?.[key]
|
||||
?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
?? key
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
|
||||
function restorePreference(): LocaleId {
|
||||
// Non-browser runs (node e2e booting the client tree) have no localStorage.
|
||||
if (typeof localStorage === 'undefined') return FALLBACK_LOCALE
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'zh' || stored === 'en') return stored
|
||||
} catch {
|
||||
// Storage access can throw (privacy mode); the default below covers it.
|
||||
}
|
||||
return FALLBACK_LOCALE
|
||||
}
|
||||
|
||||
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
|
||||
function persistPreference(id: LocaleId): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
// Storage access can throw (privacy mode / quota); the preference simply
|
||||
// does not survive the session.
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the slot registry (the feature registers its own settings row). */
|
||||
export const inject = ['slots']
|
||||
|
||||
/**
|
||||
* Client plugin body: provide the locale service with base dictionaries and
|
||||
* register the feature-owned Language preference row into the General
|
||||
* section's item slot (a feature owns its settings surface).
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const locale = new LocaleService(ctx)
|
||||
locale.register(COMMON_NS, 'zh', zh)
|
||||
locale.register(COMMON_NS, 'en', en)
|
||||
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
|
||||
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
|
||||
ctx.provide('locale', locale)
|
||||
|
||||
const store = createLanguageRowStore()
|
||||
let bound: BoundActions<typeof store> | undefined
|
||||
const sync = (snapshot: LocaleSnapshot): void => {
|
||||
bound?.sync(
|
||||
snapshot.active,
|
||||
snapshot.locales.map(l => ({ id: l.id, label: l.label })),
|
||||
snapshot.revision,
|
||||
)
|
||||
}
|
||||
ctx.on('locale/change', sync)
|
||||
const injected = (actions: BoundActions<typeof store>): LanguageRowInjected => {
|
||||
bound = actions
|
||||
// Re-sync from the getter so no event is lost between registration and
|
||||
// first render (the store's revision guard drops stale duplicates).
|
||||
sync(locale.getLocale())
|
||||
return {
|
||||
t: locale.bind(SETTINGS_NS),
|
||||
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,
|
||||
inject: injected,
|
||||
}, LanguageRow))
|
||||
return () => { deferred.dispose() }
|
||||
}, 'locale: language settings row registration')
|
||||
}
|
||||
26
packages/client/locale/src/client/settings-contract.ts
Normal file
26
packages/client/locale/src/client/settings-contract.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The `settings.general.item` slot type — one preference row inside the
|
||||
* settings General section, contributed by the feature plugin that owns the
|
||||
* preference (locale → Language, ui-theme → Appearance). Options: `id` (row
|
||||
* key), `order` (row position). Rows draw their own internals (row layout,
|
||||
* separators via CSS); the section column only stacks them.
|
||||
*
|
||||
* TYPE HOME RATIONALE: the slot is declared at runtime by
|
||||
* ui-settings-general's General entry, but its type lives here — this
|
||||
* package is the common dependency of every item registrant (any settings
|
||||
* row carries copy, so every registrant already depends on locale), whereas
|
||||
* the declarer's own contract is unreachable for locale/ui-theme without a
|
||||
* reference cycle.
|
||||
*/
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** One preference row inside the settings General section (see module JSDoc). */
|
||||
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of a General preference row (the section supplies nothing). */
|
||||
export interface SettingsGeneralItemOwnerProps {
|
||||
/** Marker field: item owner props are intentionally empty. */
|
||||
children?: never
|
||||
}
|
||||
47
packages/client/locale/src/client/settings-store.ts
Normal file
47
packages/client/locale/src/client/settings-store.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Language row slot store: a mirror of the locale service snapshot. The
|
||||
* plugin's apply-world change listener is the only writer; the row component
|
||||
* reads via props.useStore.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One selectable locale row (id + self-described label). */
|
||||
export interface LanguageOptionRow {
|
||||
/** Locale id (the setLocale argument). */
|
||||
id: string
|
||||
/** Display name in its own language (中文 / English). */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Store state mirrored from the locale snapshot. */
|
||||
export interface LanguageRowState {
|
||||
/** Active locale id. */
|
||||
active: string
|
||||
/** Selectable locales in display order. */
|
||||
options: LanguageOptionRow[]
|
||||
/** Service revision; -1 until first sync so revision 0 lands as a change. */
|
||||
revision: number
|
||||
}
|
||||
|
||||
/** Declared action shape giving the exported factory a stable return type. */
|
||||
type LanguageRowActions = {
|
||||
sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the Language row state and write surface.
|
||||
* @returns the store handle.
|
||||
*/
|
||||
export function createLanguageRowStore(): EngineStoreHandle<LanguageRowState, LanguageRowActions> {
|
||||
return defineStore({
|
||||
init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }),
|
||||
actions: {
|
||||
sync: (d, active: string, options: LanguageOptionRow[], revision: number) => {
|
||||
if (revision <= d.revision) return
|
||||
d.active = active
|
||||
d.options = options
|
||||
d.revision = revision
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
6
packages/client/locale/src/css-modules.d.ts
vendored
Normal file
6
packages/client/locale/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the i18n plugin. */
|
||||
/** Host plugin body — no host-side behavior for the locale plugin. */
|
||||
export function apply(): void {}
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`.
|
||||
* @module @deepseek-ai/dsh-client-i18n/invariant
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-locale`.
|
||||
* @module @deepseek-ai/dsh-client-locale/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n'
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-i18n-invariant'
|
||||
export const name = 'client-locale-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
116
packages/client/locale/tests/apply.spec.ts
Normal file
116
packages/client/locale/tests/apply.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/** locale apply wiring: service + dictionaries provision, declaration-aware
|
||||
* Language row registration, snapshot projection into the row store, and
|
||||
* recovery after an HMR collapse of the declaring entry. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LanguageRow } from '../src/client/LanguageRow.tsx'
|
||||
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
const SLOT = 'settings.general.item'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
}
|
||||
|
||||
/** Stand in for the settings shell: declare the General item slot from root. */
|
||||
function declareItems(slots: SlotsService): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
|
||||
/** Mirror the framework's inject choreography: bake a real instance from the
|
||||
* declared handle and hand its actions to the entry's inject factory. */
|
||||
function faceOf(slots: SlotsService) {
|
||||
const entry = slots.entries(SLOT).find(e => e.component === LanguageRow)!
|
||||
const handle = entry.store as ReturnType<typeof createLanguageRowStore>
|
||||
const instance = handle.create()
|
||||
const face = (entry.inject as unknown as (a: typeof instance.actions) => LanguageRowInjected)(instance.actions)
|
||||
return { entry, instance, face }
|
||||
}
|
||||
|
||||
describe('locale apply', () => {
|
||||
it('declares the slot service', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
})
|
||||
|
||||
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
|
||||
const before = await bench()
|
||||
declareItems(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const locale = before.ctx.get('locale') as LocaleService
|
||||
// Base dictionaries are registered: the (ns, locale) seats are occupied.
|
||||
expect(() => locale.register('common', 'zh', {})).toThrow('already has locale')
|
||||
expect(() => locale.register('common', 'en', {})).toThrow('already has locale')
|
||||
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
|
||||
const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)!
|
||||
expect(entry.options).toMatchObject({ id: 'language', order: 0 })
|
||||
|
||||
const after = await bench()
|
||||
const fiber = after.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(after.slots.entries(SLOT)).toHaveLength(0)
|
||||
declareItems(after.slots)
|
||||
await Promise.resolve()
|
||||
expect(after.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true)
|
||||
})
|
||||
|
||||
it('projects service snapshots into the row store and routes face writes back', async () => {
|
||||
const b = await bench()
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const locale = b.ctx.get('locale') as LocaleService
|
||||
// An event ahead of any inject hits the unbound-actions arm.
|
||||
locale.setLocale('en')
|
||||
|
||||
const { instance, face } = faceOf(b.slots)
|
||||
// The inject-time re-sync sealed the init window: the mirror is current.
|
||||
expect(instance.getSnapshot().active).toBe('en')
|
||||
expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
|
||||
expect(face.t('language.title')).toBe('Language')
|
||||
|
||||
face.setLocale('zh')
|
||||
expect(locale.getLocale().active).toBe('zh')
|
||||
expect(instance.getSnapshot().active).toBe('zh')
|
||||
expect(face.t('language.title')).toBe('语言')
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
const b = await bench()
|
||||
const host = declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries(SLOT)).toHaveLength(1)
|
||||
|
||||
// Collapse: the declarer dies, the cascade removes our entry while the
|
||||
// apply closure still holds its (now stale) disposer.
|
||||
host()
|
||||
expect(b.slots.entries(SLOT)).toHaveLength(0)
|
||||
|
||||
declareItems(b.slots)
|
||||
await Promise.resolve()
|
||||
expect(b.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true)
|
||||
})
|
||||
|
||||
it('teardown removes the row; teardown without a declaration is quiet', async () => {
|
||||
const b = await bench()
|
||||
declareItems(b.slots)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(b.slots.entries(SLOT)).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries(SLOT)).toHaveLength(0)
|
||||
|
||||
// Never-declared bench: the effect disposer's dispose arm stays undefined.
|
||||
const quiet = await bench()
|
||||
const f2 = quiet.ctx.plugin({ inject: [...inject], apply })
|
||||
await f2.await()
|
||||
await f2.dispose()
|
||||
expect(quiet.slots.entries(SLOT)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
34
packages/client/locale/tests/invariant.spec.ts
Normal file
34
packages/client/locale/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale'
|
||||
import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', () => {
|
||||
nodeApply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
|
||||
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
|
||||
// The feature registers its own Language settings row, hence the slots edge.
|
||||
expect(inject).toEqual(['slots'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
const locale = ctx.get('locale')
|
||||
expect(locale).toBeInstanceOf(LocaleService)
|
||||
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
|
||||
expect(() => (locale as LocaleService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
|
||||
expect(() => (locale as LocaleService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
|
||||
})
|
||||
})
|
||||
82
packages/client/locale/tests/language-row.spec.tsx
Normal file
82
packages/client/locale/tests/language-row.spec.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
/** LanguageRow behavior: selector pill shows the active locale, the menu
|
||||
* opens/closes, and selection drives setLocale. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { LanguageRow } from '../src/client/LanguageRow.tsx'
|
||||
import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx'
|
||||
import { createLanguageRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
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' })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
function mount(active = 'en') {
|
||||
// Real store instance — the sanctioned zero-machinery path for tests.
|
||||
const store = createLanguageRowStore().create()
|
||||
store.actions.sync(active, OPTIONS, 0)
|
||||
const setLocale = vi.fn()
|
||||
const props: LanguageRowComponentProps = {
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useStore: bindSnapshotSelector(store),
|
||||
actions: store.actions,
|
||||
t: (key: string) => key === 'language.title' ? 'Language' : key,
|
||||
setLocale,
|
||||
}
|
||||
render(<LanguageRow {...props} />)
|
||||
return { store, setLocale }
|
||||
}
|
||||
|
||||
describe('LanguageRow', () => {
|
||||
it('shows the title and the active locale label on the selector pill', () => {
|
||||
mount('en')
|
||||
expect(screen.getByText('Language')).toBeDefined()
|
||||
const trigger = screen.getByRole('button', { name: /English/ })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('opens the menu, selects a locale, and closes', () => {
|
||||
const b = mount('en')
|
||||
const trigger = screen.getByRole('button', { name: /English/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '中文' }))
|
||||
expect(b.setLocale).toHaveBeenCalledWith('zh')
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
|
||||
})
|
||||
|
||||
it('closes on outside pointerdown without selecting', () => {
|
||||
const b = mount('en')
|
||||
fireEvent.click(screen.getByRole('button', { name: /English/ }))
|
||||
expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
|
||||
expect(b.setLocale).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('follows store changes; an unknown active id falls back to the id itself', () => {
|
||||
const b = mount('en')
|
||||
act(() => { b.store.actions.sync('zh', OPTIONS, 1) })
|
||||
expect(screen.getByRole('button', { name: /中文/ })).toBeDefined()
|
||||
act(() => { b.store.actions.sync('fr', OPTIONS, 2) })
|
||||
expect(screen.getByRole('button', { name: /fr/ })).toBeDefined()
|
||||
})
|
||||
})
|
||||
102
packages/client/locale/tests/locale.spec.ts
Normal file
102
packages/client/locale/tests/locale.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
|
||||
const ctx = new Context()
|
||||
const events: LocaleSnapshot[] = []
|
||||
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
|
||||
return { ctx, svc: new LocaleService(ctx), events }
|
||||
}
|
||||
|
||||
describe('LocaleService', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('translates through the active-locale -> zh -> key chain', () => {
|
||||
const { svc } = make()
|
||||
svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
|
||||
svc.register('ns', 'en', { hello: 'Hello' })
|
||||
const t = svc.bind('ns')
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
expect(t('hello')).toBe('你好')
|
||||
svc.setLocale('en')
|
||||
expect(t('hello')).toBe('Hello')
|
||||
expect(t('onlyZh')).toBe('仅中文')
|
||||
expect(t('missing.key')).toBe('missing.key')
|
||||
})
|
||||
|
||||
it('interpolates {name} params and leaves unknown placeholders intact', () => {
|
||||
const { svc } = make()
|
||||
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
|
||||
const t = svc.bind('ns')
|
||||
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
|
||||
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
|
||||
})
|
||||
|
||||
it('bind returns a stable per-namespace function identity', () => {
|
||||
const { svc } = make()
|
||||
expect(svc.bind('a')).toBe(svc.bind('a'))
|
||||
expect(svc.bind('a')).not.toBe(svc.bind('b'))
|
||||
})
|
||||
|
||||
it('rejects duplicate (ns, locale) and disposer only removes its own dict', () => {
|
||||
const { svc } = make()
|
||||
const dispose = svc.register('ns', 'zh', { k: 'v1' })
|
||||
expect(() => svc.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
|
||||
dispose()
|
||||
const t = svc.bind('ns')
|
||||
expect(t('k')).toBe('k')
|
||||
svc.register('ns', 'zh', { k: 'v2' })
|
||||
expect(t('k')).toBe('v2')
|
||||
dispose()
|
||||
expect(t('k')).toBe('v2')
|
||||
})
|
||||
|
||||
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const { svc, events } = make()
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toBe(svc.getLocale())
|
||||
expect(events[0]!.revision).toBe(1)
|
||||
svc.setLocale('en')
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws on unknown locale ids', () => {
|
||||
const { svc } = make()
|
||||
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
|
||||
})
|
||||
|
||||
it('restores a persisted locale and falls back to zh on garbage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'en')
|
||||
expect(make().svc.getLocale().active).toBe('en')
|
||||
localStorage.setItem(STORAGE_KEY, 'fr')
|
||||
expect(make().svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
try {
|
||||
const { svc } = make()
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the two shipped locales with self-described labels', () => {
|
||||
const { svc } = make()
|
||||
expect(svc.getLocale().locales).toEqual([
|
||||
{ id: 'zh', label: '中文' },
|
||||
{ id: 'en', label: 'English' },
|
||||
])
|
||||
})
|
||||
})
|
||||
30
packages/client/locale/tests/settings-store.spec.ts
Normal file
30
packages/client/locale/tests/settings-store.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/** Language row store: snapshot-mirror action and the revision guard. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLanguageRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
|
||||
|
||||
describe('createLanguageRowStore', () => {
|
||||
it('init shape: empty mirror with revision at -1', () => {
|
||||
const store = createLanguageRowStore().create()
|
||||
expect(store.getSnapshot()).toEqual({ active: '', options: [], revision: -1 })
|
||||
})
|
||||
|
||||
it('sync mirrors the snapshot and advances the revision', () => {
|
||||
const store = createLanguageRowStore().create()
|
||||
store.actions.sync('zh', OPTIONS, 0)
|
||||
expect(store.getSnapshot()).toEqual({ active: 'zh', options: OPTIONS, revision: 0 })
|
||||
store.actions.sync('en', OPTIONS, 1)
|
||||
expect(store.getSnapshot().active).toBe('en')
|
||||
expect(store.getSnapshot().revision).toBe(1)
|
||||
})
|
||||
|
||||
it('revision guard drops stale and duplicate writes', () => {
|
||||
const store = createLanguageRowStore().create()
|
||||
store.actions.sync('en', OPTIONS, 5)
|
||||
store.actions.sync('zh', OPTIONS, 4)
|
||||
store.actions.sync('zh', OPTIONS, 5)
|
||||
expect(store.getSnapshot().active).toBe('en')
|
||||
expect(store.getSnapshot().revision).toBe(5)
|
||||
})
|
||||
})
|
||||
27
packages/client/locale/tsconfig.json
Normal file
27
packages/client/locale/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/locale/tsdown.config.ts
Normal file
3
packages/client/locale/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
6
packages/client/modules/README.i18n.yaml
Normal file
6
packages/client/modules/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
|
||||
README.zh.md: 7d1aa8af08256c47c1ae65343e46c30e910128d0
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-client-modules
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
|
||||
|
||||
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).
|
||||
|
||||
22
packages/client/modules/README.zh.md
Normal file
22
packages/client/modules/README.zh.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-client-modules
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端模块系统:Node 内部 ESM loader 的浏览器端对等实现,以惰性 CJS 表构建。web 外壳挂载 vendored cordis Loader 来治理配置项(fiber 生命周期、inject 等待、update/refresh),并把该包的 `ClientModuleLoader` 作为其 `internal` seam 注入;vendored 一侧唯一的消费点是 `EntryTree.import`,因此替换 `internal` 恰好只会替换「插件代码如何到达」,不会改变其他内容。
|
||||
|
||||
惰性 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` 是第一阶段到达 hook(抓取 + 执行,只注册;并发调用共享一个进行中的 task);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取(HMR hook)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表叶;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。
|
||||
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只逐记录清点自身拥有的样式标签 id。
|
||||
6
packages/client/runtime/README.i18n.yaml
Normal file
6
packages/client/runtime/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 91c5487f9dbd6e04ff70ea560f87a89862cf37fe
|
||||
README.zh.md: 932fcb1a05fbf54a51b045559b351db0525eef1c
|
||||
@@ -1,6 +1,8 @@
|
||||
# @deepseek-ai/dsh-client-runtime
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, page-local Workspace Intent state, default-target derivation, and the cross-object New Session flow. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
@@ -8,9 +10,13 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
## Session creation failures
|
||||
## New Session and the blank mirror
|
||||
|
||||
`SessionsService.create` accepts an optional caller-preallocated SessionId. It throws `SessionCreateError` on failure: `requestedSessionId` remains available after transport uncertainty, while `publishedSessionId` is set when `workspace-attach-failed` proves the Host published a real Session before attachment failed. For the New Session flow, the frontend Session object owns its retained prompt and advances it through attachment and send; a partially published Session keeps the same object and prompt while it appears as Ungrouped.
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
|
||||
## Code Mode sub-dispatch index
|
||||
|
||||
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
|
||||
|
||||
## Session title projection
|
||||
|
||||
@@ -31,5 +37,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
|
||||
|
||||
41
packages/client/runtime/README.zh.md
Normal file
41
packages/client/runtime/README.zh.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @deepseek-ai/dsh-client-runtime
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
## New Session 与 blank 镜像
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
## Code Mode 子调用索引
|
||||
|
||||
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
|
||||
|
||||
## Session 标题投影
|
||||
|
||||
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
|
||||
|
||||
## 会话模型选择
|
||||
|
||||
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为 Session 对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。
|
||||
70
packages/client/runtime/src/client/agents/scope.ts
Normal file
70
packages/client/runtime/src/client/agents/scope.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Client Agent-scope primitive: mint a Cordis context tagged with the owning
|
||||
* Agent's identity. The mechanism mirrors the host `dsh-scope` architecture
|
||||
* (no-op plugin fiber + context tag + `Context.filter` routing predicate);
|
||||
* the shape deliberately diverges: the filter lives on the actx itself
|
||||
* instead of a separate carrier object, so scoped dispatch is plain cordis —
|
||||
* `actx.bail(actx, event, payload)` / `actx.emit(actx, ...)` — with no
|
||||
* wrapper. The host needs a detached carrier because its dispatch subject is
|
||||
* the business Agent object; client scope events carry only ids, so the
|
||||
* actx is the natural subject. The second divergence stands: the scope key
|
||||
* is the branded `SessionId` (value compared), not an object identity — the
|
||||
* agent and its session share one id (1:1, same axis; no separate AgentId
|
||||
* brand), and a client scope's identity IS that wire id. Third divergence,
|
||||
* deliberate: the client scopes the Agent IDENTITY, not a live Agent object
|
||||
* — a cold session's host Agent is already disposed while its client actx
|
||||
* stays alive for history viewing.
|
||||
*/
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Context tag written by {@link createScope}. */
|
||||
const kScope = Symbol('dsh.client.scope')
|
||||
|
||||
/** A minted Agent scope and its disposal boundary. */
|
||||
export interface AgentScopeHandle {
|
||||
/**
|
||||
* Tagged context: scope-owned registrations and scoped dispatch both go
|
||||
* through it (passing it as the dispatch subject routes to this agent's
|
||||
* tagged listeners plus every untagged one).
|
||||
*/
|
||||
ctx: Context
|
||||
/** Backing fiber (dispose tears down every scope-owned registration). */
|
||||
fiber: Fiber
|
||||
}
|
||||
|
||||
/** Shared no-op plugin backing each Agent scope fiber. */
|
||||
function agentScope(): void {}
|
||||
|
||||
/**
|
||||
* Mint an Agent scope under `ctx`: a no-op plugin fiber whose context
|
||||
* carries the agent tag and the dispatch filter — untagged listeners are
|
||||
* admitted globally, tagged listeners only for a matching agent.
|
||||
* Registrations through the returned ctx dispose with the fiber.
|
||||
* @param ctx - client root context the scope fiber mounts under.
|
||||
* @param key - owning agent identity (the routing tag; agent id === session id).
|
||||
* @returns the tagged context and its backing fiber.
|
||||
*/
|
||||
export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
|
||||
const fiber = ctx.plugin(agentScope)
|
||||
return {
|
||||
fiber,
|
||||
ctx: fiber.ctx.extend({
|
||||
[kScope]: key,
|
||||
[CordisContext.filter](listenerCtx: Context): boolean {
|
||||
const tag = scopeOf(listenerCtx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the nearest agent tag inherited by a context.
|
||||
* @param ctx - any client context.
|
||||
* @returns its agent identity (the session id), or undefined for root contexts.
|
||||
*/
|
||||
export function scopeOf(ctx: Context): SessionId | undefined {
|
||||
return (ctx as Context & { [kScope]?: SessionId })[kScope]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
@@ -11,10 +11,14 @@ import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './se
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
export { createScope } from './agents/scope.ts'
|
||||
export type { AgentScopeHandle } from './agents/scope.ts'
|
||||
export { WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
|
||||
export type { SessionIntentListSnapshot, SessionListPhase } from './sessions/manager.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase } from './sessions/manager.ts'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -24,10 +28,9 @@ export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, ModelSelectionSnapshot, ModelSelectionStatus, PendingPrompt,
|
||||
RunningToolCall, SessionIntentSnapshot, SessionIntentTarget, SteeringMessageNode,
|
||||
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, ModelSelectionSnapshot, ModelSelectionStatus, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
@@ -57,6 +60,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
}
|
||||
/** Standard kit for slots that remain mounted while current session changes. */
|
||||
interface SessionMaybeStandardProps {
|
||||
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
|
||||
/** Current session id; absent in the no-session state. */
|
||||
sessionId: SessionId | undefined
|
||||
}
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
useSessions: SnapshotSelectorHook<SessionListState>
|
||||
@@ -73,6 +82,20 @@ declare module 'cordis' {
|
||||
* @param key - the mutated SlotMap key.
|
||||
*/
|
||||
'slots/changed'(key: string): void
|
||||
/**
|
||||
* The host command registry changed (host/commands-changed passthrough).
|
||||
* Pure invalidation signal: subscribers refetch `command.list` in the
|
||||
* background rather than diffing.
|
||||
* @mode emit
|
||||
*/
|
||||
'commands/changed'(): void
|
||||
/**
|
||||
* A connection generation was (re-)established. Wire-derived caches must
|
||||
* treat their state as stale and repull (commands directory; the queue
|
||||
* mirrors reset themselves through the session resync path).
|
||||
* @mode emit
|
||||
*/
|
||||
'connection/reset'(): void
|
||||
}
|
||||
interface Context {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
@@ -92,15 +115,23 @@ export function apply(ctx: Context): void {
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
() => workspaces.startInitialSelection(),
|
||||
'runtime: initial Workspace selection',
|
||||
)
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches) subscribe on ctx.
|
||||
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
},
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
ctx.emit('connection/reset')
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type {
|
||||
ModelCatalogFailure, ModelProviderGroup, ModelTarget, RpcError, SessionId,
|
||||
ToolCallView, ToolResultView, WorkspaceId,
|
||||
ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
@@ -128,6 +128,21 @@ export type ConversationNode =
|
||||
| ToolResultNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
* One `run_code` sub-dispatch materialized in the native call-block shapes so
|
||||
* every consumer (tool rows, details panel) renders it through the exact
|
||||
* components that render a native call: a started-but-unsettled sub-call is a
|
||||
* {@link RunningToolCall} (rows derive the running state from the shape,
|
||||
* exactly as for native calls) and its `tool/code-dispatch` settlement
|
||||
* replaces it in place with the {@link ToolResultNode} form. Never part of
|
||||
* the surface `nodes` flow — sub-calls live under their parent via
|
||||
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
|
||||
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
|
||||
* and its JSON-stringified logged arguments; `content`/`isError` are the
|
||||
* settled sub-call's complete logged outcome.
|
||||
*/
|
||||
export type CodeSubCall = RunningToolCall | ToolResultNode
|
||||
|
||||
/** In-flight tool card material: tool/call seen, tool/result not yet. */
|
||||
export interface RunningToolCall {
|
||||
callId: string
|
||||
@@ -142,6 +157,12 @@ export interface RunningToolCall {
|
||||
}
|
||||
|
||||
|
||||
/** One queued-message row mirrored from `session/queued` frames (key: the enqueueing prompt's rpcId when wire-sourced). */
|
||||
export interface QueuedMessage {
|
||||
readonly key: string
|
||||
readonly preview: string
|
||||
}
|
||||
|
||||
/** In-progress assistant output (chunk accumulator product). */
|
||||
export interface PartialAssistant {
|
||||
turn: number
|
||||
@@ -197,30 +218,6 @@ export interface ModelSelectionSnapshot {
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
/** Workspace target of a frontend-only Session. */
|
||||
export type SessionIntentTarget =
|
||||
| { kind: 'workspace'; workspaceId: WorkspaceId }
|
||||
| { kind: 'workspace-intent' }
|
||||
|
||||
/** Publication state owned by a frontend Session before it joins the Host. */
|
||||
export interface SessionIntentSnapshot {
|
||||
target: SessionIntentTarget
|
||||
phase: 'ready' | 'connecting'
|
||||
error?: { step: 'session'; message: string }
|
||||
}
|
||||
|
||||
/** One editable prompt retained by its Session until the Host accepts it. */
|
||||
export interface PendingPrompt {
|
||||
text: string
|
||||
phase: 'editing' | 'sending' | 'failed'
|
||||
/** Failed prerequisite retried before sending, or the send itself. */
|
||||
retry: 'connect' | 'send'
|
||||
/** Workspace needed when retrying Session attachment. */
|
||||
workspaceId?: WorkspaceId
|
||||
/** Last failure diagnostic, absent while editing or sending. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
|
||||
export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
@@ -230,7 +227,16 @@ export interface ConversationSnapshot {
|
||||
foldDegraded: boolean
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
/**
|
||||
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
|
||||
* order. Populated from in-window `tool/code-dispatch` events (live and
|
||||
* replay identically); the per-parent array reference is stable across
|
||||
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
|
||||
*/
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
pending: readonly PendingInteraction[]
|
||||
/** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
|
||||
queue: readonly QueuedMessage[]
|
||||
running: boolean
|
||||
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
|
||||
composerPhase: ComposerPhase
|
||||
@@ -241,10 +247,17 @@ export interface ConversationSnapshot {
|
||||
hasMore: boolean
|
||||
loadingOlder: boolean
|
||||
promptError: PromptError | null
|
||||
/** Frontend-only publication state; null for a Host-connected Session. */
|
||||
intent: SessionIntentSnapshot | null
|
||||
/** Session-owned editable prompt waiting for connection, attachment, or send. */
|
||||
pendingPrompt: PendingPrompt | null
|
||||
/**
|
||||
* Whether this session still has an empty log (no user message yet).
|
||||
* Mirrors the host summary's derived blank bit: seeded from `session.list`
|
||||
* / the `host/session-added` frame, flipped false by the first ACCEPTED
|
||||
* prompt locally (on the RPC success response — acceptance proves the
|
||||
* user message is in the host log; a rejected first prompt keeps the
|
||||
* session blank and reusable) and by any `running: true` status remotely,
|
||||
* and re-aligned by every list re-pull (the summary stays authoritative).
|
||||
* Blank sessions are hidden from session lists and reused by New Session.
|
||||
*/
|
||||
blank: boolean
|
||||
lastAgentError: string | null
|
||||
/** Session-local model target and advisory directory state. */
|
||||
modelSelection: ModelSelectionSnapshot
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SessionListEntry {
|
||||
title?: string
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
cwd?: string
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { Session } from './session.ts'
|
||||
import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation.ts'
|
||||
|
||||
/**
|
||||
* List arrival lifecycle, orthogonal to the pull-activity `state` axis:
|
||||
@@ -23,19 +22,11 @@ import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation.
|
||||
*/
|
||||
export type SessionListPhase = 'pending' | 'ready'
|
||||
|
||||
/** Session-owned frontend Intent projected into the global list snapshot. */
|
||||
export interface SessionIntentListSnapshot extends SessionIntentSnapshot {
|
||||
sessionId: SessionId
|
||||
prompt: string
|
||||
}
|
||||
|
||||
/** Immutable session-list snapshot for useSessionList. */
|
||||
export interface SessionListSnapshot {
|
||||
items: readonly SessionListEntry[]
|
||||
/** Selected real or frontend-only Session id. */
|
||||
/** Selected Session id (validated against items; masked to undefined while its session is off the list). */
|
||||
current: SessionId | undefined
|
||||
/** Sole page-local frontend Session projection; its state remains owned by Session. */
|
||||
intent: SessionIntentListSnapshot | undefined
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
|
||||
phase: SessionListPhase
|
||||
@@ -46,6 +37,8 @@ type SessionListMutation =
|
||||
| { kind: 'upsert'; summary: SessionSummary }
|
||||
| { kind: 'remove'; sessionId: SessionId }
|
||||
| { kind: 'status'; sessionId: SessionId; running: boolean }
|
||||
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
|
||||
| { kind: 'engaged'; sessionId: SessionId }
|
||||
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
@@ -76,8 +69,6 @@ export class SessionManager {
|
||||
private listMutations: SessionListMutation[] | null = null
|
||||
|
||||
private selected: SessionId | undefined
|
||||
private intentSessionId: SessionId | undefined
|
||||
private stopIntentWatch: (() => void) | undefined
|
||||
|
||||
private listSnapshotCache: SessionListSnapshot
|
||||
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
|
||||
@@ -101,80 +92,38 @@ export class SessionManager {
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
}
|
||||
|
||||
// ---- Selection and client-local intents ----
|
||||
// ---- Selection ----
|
||||
|
||||
/**
|
||||
* Select a real Session and discard the unmaterialized intent.
|
||||
* @param sessionId - listed real Session id.
|
||||
* Select a listed Session.
|
||||
* @param sessionId - listed Session id.
|
||||
*/
|
||||
select(sessionId: SessionId): void {
|
||||
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
|
||||
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
||||
}
|
||||
this.discardIntent()
|
||||
this.selected = sessionId
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/** Clear selection and abandon any frontend-only Session. */
|
||||
/** Clear the selection (the layout falls to the no-session view state). */
|
||||
clearSelection(): void {
|
||||
this.discardIntent()
|
||||
this.selected = undefined
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a frontend Session against a real or still-local Workspace target.
|
||||
* @param target - real Workspace or the WorkspacesService-owned local target.
|
||||
* @param prompt - optional prompt retained when retargeting from a picker.
|
||||
* @returns the frontend Session object that owns the Intent.
|
||||
*/
|
||||
startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
||||
this.discardIntent()
|
||||
const sessionId = `client-session-${crypto.randomUUID()}` as SessionId
|
||||
const session = this.createSession(sessionId, { target, prompt })
|
||||
this.sessions.set(sessionId, session)
|
||||
this.intentSessionId = sessionId
|
||||
this.selected = sessionId
|
||||
this.stopIntentWatch = session.subscribe(() => {
|
||||
if (this.intentSessionId !== sessionId) return
|
||||
if (session.getSnapshot().intent === null) {
|
||||
this.intentSessionId = undefined
|
||||
this.stopIntentWatch?.()
|
||||
this.stopIntentWatch = undefined
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.notifier.notifyNow()
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active frontend Session Intent.
|
||||
* @returns the active frontend Session, if one remains selected.
|
||||
*/
|
||||
getIntent(): Session | undefined {
|
||||
return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the retained prompt of the active frontend Session.
|
||||
* @param text - exact controlled-input value for the active frontend Session.
|
||||
*/
|
||||
updateIntent(text: string): void {
|
||||
this.getIntent()?.updatePendingPrompt(text)
|
||||
}
|
||||
|
||||
private discardIntent(): void {
|
||||
const session = this.getIntent()
|
||||
this.intentSessionId = undefined
|
||||
this.stopIntentWatch?.()
|
||||
this.stopIntentWatch = undefined
|
||||
session?.abandonIntent()
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
/**
|
||||
* Drop a session instance (scope-prune companion, decision 12: instance
|
||||
* and scope share one lifecycle). The host session log is the durable
|
||||
* truth — a later get() lazily rebuilds and open() backfills history.
|
||||
* @param sessionId - the session to drop.
|
||||
*/
|
||||
drop(sessionId: SessionId): void {
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy build: return the existing instance or construct one (no auto-open —
|
||||
* open is triggered by the container's select callback).
|
||||
@@ -186,31 +135,33 @@ export class SessionManager {
|
||||
if (session === undefined) {
|
||||
session = this.createSession(sessionId)
|
||||
this.sessions.set(sessionId, session)
|
||||
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
|
||||
const summary = this.summaries.find(s => s.sessionId === sessionId)
|
||||
if (summary !== undefined) session.handleRunning(summary.running)
|
||||
// Replay approval/question frames buffered before instantiation (rpcId verbatim, same semantics as the subscribed baseline replay).
|
||||
// Replay approval/question/queued frames buffered before instantiation (rpcId
|
||||
// verbatim, same semantics as the subscribed baseline replay). Replay happens
|
||||
// BEFORE the running-bit sync: a not-running summary must sweep replayed queue
|
||||
// rows the same way a live status flip would (their retirement events dropped
|
||||
// while the session was uninstantiated).
|
||||
const buffered = this.pendingBuffers.get(sessionId)
|
||||
if (buffered !== undefined) {
|
||||
this.pendingBuffers.delete(sessionId)
|
||||
for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload)
|
||||
}
|
||||
// Sync the running and blank bits from the list snapshot into the new
|
||||
// instance (consistency when the list precedes open).
|
||||
const summary = this.summaries.find(s => s.sessionId === sessionId)
|
||||
if (summary !== undefined) {
|
||||
session.handleBlank(summary.blank)
|
||||
session.handleRunning(summary.running)
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private createSession(
|
||||
sessionId: SessionId,
|
||||
intent?: { target: SessionIntentTarget; prompt: string },
|
||||
): Session {
|
||||
private createSession(sessionId: SessionId): Session {
|
||||
return new Session(sessionId, this.api, {
|
||||
...(intent === undefined ? {} : { intent }),
|
||||
onPublished: (published) => {
|
||||
this.sessions.set(published.sessionId, published)
|
||||
this.recordMutation({
|
||||
kind: 'upsert',
|
||||
summary: { sessionId: published.sessionId, updatedAt: Date.now(), running: false },
|
||||
})
|
||||
// The sender's local first-send flip mirrors into the list row so the
|
||||
// session surfaces (lists filter on blank) before any host frame lands.
|
||||
onEngaged: (engaged) => {
|
||||
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -237,8 +188,13 @@ export class SessionManager {
|
||||
this.summaries = summaries
|
||||
this.listState = 'idle'
|
||||
this.listPhase = 'ready'
|
||||
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
|
||||
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
for (const s of this.summaries) {
|
||||
const session = this.sessions.get(s.sessionId)
|
||||
if (session === undefined) continue
|
||||
session.handleBlank(s.blank)
|
||||
session.handleRunning(s.running)
|
||||
}
|
||||
} else {
|
||||
this.listState = 'error'
|
||||
this.listError = result.error
|
||||
@@ -259,7 +215,8 @@ export class SessionManager {
|
||||
|
||||
/**
|
||||
* Contract session.create; on success merge into summaries immediately (no
|
||||
* wait for the next refresh).
|
||||
* wait for the next refresh). A created session is blank by definition
|
||||
* (entity birth precedes the first message).
|
||||
* @param opts - target workspace or working directory, plus an optional caller-owned id.
|
||||
* @returns the create result.
|
||||
*/
|
||||
@@ -267,16 +224,14 @@ export class SessionManager {
|
||||
opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
|
||||
): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
try {
|
||||
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
|
||||
const payload = opts.workspaceId !== undefined
|
||||
? { workspaceId: opts.workspaceId, ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }) }
|
||||
: {
|
||||
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
|
||||
...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
|
||||
}
|
||||
? { workspaceId: opts.workspaceId, ...shared }
|
||||
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
|
||||
const { result } = await this.api.sessions.create(payload)
|
||||
if (result.ok) {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false,
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
|
||||
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
||||
} })
|
||||
} else {
|
||||
@@ -289,6 +244,7 @@ export class SessionManager {
|
||||
sessionId: publishedSessionId,
|
||||
updatedAt: Date.now(),
|
||||
running: false,
|
||||
blank: true,
|
||||
} })
|
||||
}
|
||||
}
|
||||
@@ -363,16 +319,31 @@ export class SessionManager {
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
// New mux-generation baseline: buffered session/queued frames belong to
|
||||
// the previous generation and the host is about to resend the live
|
||||
// snapshot — drop them, or every reconnect appends a duplicate batch
|
||||
// (and enough reconnects push real approval/question frames past the
|
||||
// cap). Same re-baseline signal Session uses for its own mirror.
|
||||
const buffered = this.pendingBuffers.get(frame.sessionId)
|
||||
if (buffered !== undefined) {
|
||||
const kept = buffered.filter(item => item.payload.type !== 'session/queued')
|
||||
if (kept.length !== buffered.length) {
|
||||
if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId)
|
||||
else this.pendingBuffers.set(frame.sessionId, kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
const session = this.sessions.get(frame.sessionId)
|
||||
if (session === undefined) {
|
||||
// Approval/question frames never hit history: buffer for replay on instantiation;
|
||||
// everything else drops (not instantiated — history fully backfills on open).
|
||||
// Approval/question/queued frames never hit history: buffer for replay on
|
||||
// instantiation; everything else drops (not instantiated — history fully
|
||||
// backfills on open).
|
||||
switch (frame.type) {
|
||||
case 'approval/requested':
|
||||
case 'approval/resolved':
|
||||
case 'question/requested':
|
||||
case 'question/resolved': {
|
||||
case 'question/resolved':
|
||||
case 'session/queued': {
|
||||
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
|
||||
buffer.push(envelope)
|
||||
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
|
||||
@@ -395,11 +366,11 @@ export class SessionManager {
|
||||
switch (frame.type) {
|
||||
case 'host/session-added': {
|
||||
this.mergeSummary({
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handlePublished()
|
||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
@@ -441,6 +412,7 @@ export class SessionManager {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.title === entry.title && prev.depth === entry.depth
|
||||
) return prev
|
||||
@@ -452,24 +424,13 @@ export class SessionManager {
|
||||
}
|
||||
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
|
||||
if (!sameOrder) this.itemsCache = items
|
||||
const intentSession = this.getIntent()
|
||||
const intentState = intentSession?.getSnapshot()
|
||||
const intent = intentSession !== undefined
|
||||
&& intentState !== undefined && intentState.intent !== null && intentState.pendingPrompt !== null
|
||||
? {
|
||||
sessionId: intentSession.sessionId,
|
||||
...intentState.intent,
|
||||
prompt: intentState.pendingPrompt.text,
|
||||
}
|
||||
: undefined
|
||||
const selected = this.selected
|
||||
const current = selected !== undefined && (
|
||||
intent?.sessionId === selected || items.some(item => item.sessionId === selected)
|
||||
) ? selected : undefined
|
||||
const current = selected !== undefined && items.some(item => item.sessionId === selected)
|
||||
? selected
|
||||
: undefined
|
||||
return {
|
||||
items: this.itemsCache,
|
||||
current,
|
||||
intent,
|
||||
state: this.listState,
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
@@ -485,18 +446,29 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
|
||||
if (existing === undefined) return [mutation.summary, ...summaries]
|
||||
const filled: SessionSummary = {
|
||||
...existing,
|
||||
// Blank only lowers: a stale true (session-added racing the local
|
||||
// first send) never re-hides an already-surfaced session.
|
||||
blank: existing.blank && mutation.summary.blank,
|
||||
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
|
||||
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
|
||||
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
||||
}
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId) return [...summaries]
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
|
||||
&& filled.blank === existing.blank) return [...summaries]
|
||||
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
|
||||
}
|
||||
case 'remove':
|
||||
return summaries.filter(summary => summary.sessionId !== mutation.sessionId)
|
||||
case 'status':
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.running !== mutation.running
|
||||
? { ...summary, running: mutation.running }
|
||||
// running:true doubles as the cross-端 blank flip (a blank session
|
||||
// never runs, so the first running frame proves a message landed).
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId
|
||||
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
|
||||
? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
|
||||
: summary)
|
||||
case 'engaged':
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank
|
||||
? { ...summary, blank: false }
|
||||
: summary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
|
||||
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
|
||||
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
|
||||
//
|
||||
// Freshness and notification are SEPARATE bits: a pull (ensureFresh) between
|
||||
// markDirty and the scheduled flush rebuilds the snapshot but must not
|
||||
// swallow the notification — push subscribers (object-layer watchers) would
|
||||
// otherwise starve whenever any reader pulls first.
|
||||
|
||||
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
|
||||
export class Notifier {
|
||||
private listeners = new Set<() => void>()
|
||||
private dirty = false
|
||||
private notifyPending = false
|
||||
private scheduled = false
|
||||
|
||||
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
|
||||
@@ -28,14 +34,18 @@ export class Notifier {
|
||||
/** State-change entry: mark dirty and schedule the batched flush. */
|
||||
markDirty(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.scheduled) return
|
||||
this.scheduled = true
|
||||
queueMicrotask(() => {
|
||||
this.scheduled = false
|
||||
if (!this.dirty) return
|
||||
if (this.listeners.size === 0) return // lazy: no subscribers, keep dirty for the next getSnapshot
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
if (!this.notifyPending) return
|
||||
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
|
||||
this.notifyPending = false
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
}
|
||||
for (const listener of this.listeners) listener()
|
||||
})
|
||||
}
|
||||
@@ -46,13 +56,18 @@ export class Notifier {
|
||||
*/
|
||||
notifyNow(): void {
|
||||
this.dirty = true
|
||||
this.notifyPending = true
|
||||
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
|
||||
this.notifyPending = false
|
||||
this.dirty = false
|
||||
this.rebuild()
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
|
||||
/** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). */
|
||||
/**
|
||||
* Pre-getSnapshot check: rebuild synchronously when dirty (read path
|
||||
* before first subscribe / while unobserved). Notification stays pending.
|
||||
*/
|
||||
ensureFresh(): void {
|
||||
if (!this.dirty) return
|
||||
this.dirty = false
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
|
||||
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||
* id), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||
* resolution (pure — resolution has no side effects and is render-safe);
|
||||
@@ -16,15 +17,15 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type {
|
||||
SessionIntentListSnapshot, SessionListPhase,
|
||||
} from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
import type { SessionIntentTarget } from './conversation.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
@@ -36,6 +37,13 @@ export interface SessionSummary {
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
* store carries every row, while the Workspace browser shows only the
|
||||
* selected blank entry.
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
@@ -48,17 +56,13 @@ export interface SessionListState {
|
||||
ids: SessionId[]
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
current: SessionId | undefined
|
||||
/** Frontend Session Intent projected from its owning Session object. */
|
||||
intent: SessionIntentListSnapshot | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
}
|
||||
|
||||
/** Structured session-create failure preserving partial publication identity. */
|
||||
/** Structured session-create failure. */
|
||||
export class SessionCreateError extends Error {
|
||||
override readonly name = 'SessionCreateError'
|
||||
/** Definitely published by Host before Workspace attachment failed. */
|
||||
readonly publishedSessionId: SessionId | undefined
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
@@ -69,9 +73,6 @@ export class SessionCreateError extends Error {
|
||||
readonly requestedSessionId: SessionId | undefined,
|
||||
) {
|
||||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
this.publishedSessionId = rpcError.code === 'workspace-attach-failed'
|
||||
? rpcError.details.sessionId
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,20 +83,10 @@ export interface SessionBinding {
|
||||
readonly ctx: Context
|
||||
}
|
||||
|
||||
/** Scope tag key (client counterpart of the host dsh-scope pattern). */
|
||||
const kScope = Symbol('dsh.client.scope')
|
||||
|
||||
/**
|
||||
* Read the session scope tag off a context.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
export function scopeOf(ctx: Context): SessionId | undefined {
|
||||
return (ctx as Context & { [kScope]?: SessionId })[kScope]
|
||||
}
|
||||
|
||||
/** Shared no-op plugin backing each session scope fiber. */
|
||||
function sessionScope(): void {}
|
||||
// Scope primitives live in ../agents/scope.ts (the client mirror of host
|
||||
// dsh-scope, keyed by Agent identity); re-exported here so existing
|
||||
// consumers keep their import site.
|
||||
export { scopeOf } from '../agents/scope.ts'
|
||||
|
||||
/**
|
||||
* Workspace display title of a session cwd: the path's last non-empty
|
||||
@@ -128,8 +119,30 @@ interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
/** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
|
||||
cell: SessionCell
|
||||
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
|
||||
provideInfo: SessionProvideInfo
|
||||
}
|
||||
|
||||
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
|
||||
export interface SessionProvideContribution {
|
||||
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
|
||||
hooks?: Record<string, HostObservable<unknown>>
|
||||
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Static declaration plus per-session resolver for one standard-kit
|
||||
* contribution. The declared names let the renderer construct the same hook
|
||||
* and prop surface while no session is current.
|
||||
*/
|
||||
export interface SessionProvideDescriptor {
|
||||
/** Hook base names (`input` becomes `useInput`). */
|
||||
hooks?: readonly string[]
|
||||
/** Plain standard-prop names. */
|
||||
props?: readonly string[]
|
||||
/** Resolve every declared member for one definite session. */
|
||||
resolve(binding: SessionBinding): SessionProvideContribution
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
@@ -150,6 +163,10 @@ export class SessionsService {
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Registered per-session standard-props providers, in registration order. */
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
@@ -170,7 +187,7 @@ export class SessionsService {
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'pending',
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
@@ -182,9 +199,97 @@ export class SessionsService {
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
// The runtime's own contribution comes first: useSession rides the same
|
||||
// provide channel every plugin uses (no renderer special case).
|
||||
this.providers.push({
|
||||
hooks: ['session'],
|
||||
resolve: binding => ({ hooks: { session: binding.session } }),
|
||||
})
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider: every session-scope slot
|
||||
* component receives the contributed members as standard props (`hooks`
|
||||
* sources become `use<Name>` selector hooks on the render side; `props`
|
||||
* spread verbatim). Contributions materialize lazily with the session's
|
||||
* scope record and die with it. Registration order is resolution order;
|
||||
* duplicate member names fail loud at materialization.
|
||||
* @param descriptor - static member roster plus per-session resolver.
|
||||
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
|
||||
*/
|
||||
provide(descriptor: SessionProvideDescriptor): () => void {
|
||||
this.providers.push(descriptor)
|
||||
// Scopes may already exist (boot order: the list lands and resolves
|
||||
// scopes before later plugins register) — their bundles must include
|
||||
// every provider by first render, so re-materialize on roster change.
|
||||
this.rematerializeProvideBundles()
|
||||
return () => {
|
||||
const at = this.providers.indexOf(descriptor)
|
||||
if (at >= 0) this.providers.splice(at, 1)
|
||||
this.rematerializeProvideBundles()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
|
||||
private rematerializeProvideBundles(): void {
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
|
||||
const hooks: Record<string, undefined> = {}
|
||||
const props: Record<string, undefined> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = undefined
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = undefined
|
||||
}
|
||||
}
|
||||
return { sessionId: undefined, hooks, props }
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
|
||||
const hooks: Record<string, HostObservable<unknown>> = {}
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
const contribution = descriptor.resolve(binding)
|
||||
const contributedHooks = contribution.hooks ?? {}
|
||||
const contributedProps = contribution.props ?? {}
|
||||
for (const name of Object.keys(contributedHooks)) {
|
||||
if (!(descriptor.hooks ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared hook "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(contributedProps)) {
|
||||
if (!(descriptor.props ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared prop "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
const source = contributedHooks[name]
|
||||
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = source
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = contributedProps[name]
|
||||
}
|
||||
}
|
||||
return { sessionId: binding.sessionId, hooks, props }
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere.
|
||||
@@ -205,32 +310,6 @@ export class SessionsService {
|
||||
this.manager.clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or retarget the sole client-local Session intent.
|
||||
* @param target - resolved real or frontend-only Workspace target.
|
||||
* @param prompt - optional prompt retained across retargeting.
|
||||
* @returns the frontend Session object that owns the Intent.
|
||||
*/
|
||||
startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
||||
return this.manager.startIntent(target, prompt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active frontend Session Intent.
|
||||
* @returns the active frontend Session object, if one exists.
|
||||
*/
|
||||
intent(): Session | undefined {
|
||||
return this.manager.getIntent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the retained prompt of the active frontend Session.
|
||||
* @param text - exact controlled-input value for the current Session Intent.
|
||||
*/
|
||||
updateIntent(text: string): void {
|
||||
this.manager.updateIntent(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started baseline pull.
|
||||
@@ -261,21 +340,26 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* Create a session on the host. Resolution guarantee: by the time the
|
||||
* promise resolves, the created session is in the list store and
|
||||
* {@link SessionsService.binding} resolves it — callers (New Session
|
||||
* draft hand-off) may address the scope synchronously, without waiting a
|
||||
* notifier flush. The synchronous projection below makes this structural
|
||||
* rather than an accident of microtask ordering.
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @throws {SessionCreateError} with the requested id and, after an attach
|
||||
* failure, the definitely published id.
|
||||
* @throws {SessionCreateError} with the requested id.
|
||||
*/
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session-scoped context view (use-and-discard).
|
||||
* @param id - session id.
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined {
|
||||
@@ -283,7 +367,7 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session scope tag off a context. Service-method seam: fetch
|
||||
* Read the Agent scope tag off a context. Service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
@@ -291,7 +375,22 @@ export class SessionsService {
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeOf(ctx)
|
||||
return scopeTagOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the business Session behind an Agent-scoped context — the one
|
||||
* hop every scoped consumer (event listeners, per-session controllers)
|
||||
* takes from ctx-space into object-space (the client mirror of host
|
||||
* `agent.session`). Same service-method seam as
|
||||
* {@link SessionsService.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): Session | undefined {
|
||||
const id = scopeTagOf(ctx)
|
||||
if (id === undefined) return undefined
|
||||
return this.scopes.get(id)?.binding.session
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,16 +404,26 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer session cell (SessionProvider's feed through
|
||||
* the renderer host; ctx never enters the render layer). Pure resolution —
|
||||
* render-safe: SessionProvider calls this during render, so no staging, no
|
||||
* window side effects (StrictMode double-invokes and concurrent discarded
|
||||
* passes must stay free).
|
||||
* Resolve the render-layer standard-props bundle (SessionProvider's feed
|
||||
* through the renderer host; ctx never enters the render layer). Pure
|
||||
* resolution — render-safe: SessionProvider calls this during render, so no
|
||||
* staging, no window side effects (StrictMode double-invokes and concurrent
|
||||
* discarded passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns cell, or undefined for a session neither listed nor already scoped.
|
||||
* @returns the provide info, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
cell(id: string): SessionCell | undefined {
|
||||
return this.resolve(id as SessionId)?.cell
|
||||
provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
return this.resolve(id as SessionId)?.provideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current-session-optional standard kit. Unknown or absent ids
|
||||
* return the static no-session projection rather than removing hook props.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns a definite or no-session provide bundle.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,29 +469,42 @@ export class SessionsService {
|
||||
return chain
|
||||
}
|
||||
|
||||
/** Lazily mint the scope + binding for a listed (or already-scoped) session. */
|
||||
/**
|
||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||
* prune share one predicate (decision 12): listed on the host — a scope is
|
||||
* born when its session enters the client's view (list mirror row from the
|
||||
* baseline pull, a create() echo, or the session-added frame) and dies with
|
||||
* the prune when the row leaves.
|
||||
*/
|
||||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||||
const existing = this.scopes.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
// Frozen scopes outlive the list; new scopes are only minted for listed sessions.
|
||||
if (this.list.getSnapshot().byId[id] === undefined) return undefined
|
||||
const fiber = this.rootCtx.plugin(sessionScope)
|
||||
const ctx = fiber.ctx.extend({ [kScope]: id })
|
||||
if (!this.eligible(id)) return undefined
|
||||
const { fiber, ctx } = createScope(this.rootCtx, id)
|
||||
const session = this.manager.get(id)
|
||||
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
|
||||
// mint and bind are one step so a live scope record implies a bound actx.
|
||||
session.bindScope(ctx)
|
||||
const binding: SessionBinding = { sessionId: id, session, ctx }
|
||||
const record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding: { sessionId: id, session, ctx },
|
||||
// Session is the observable; React binds a selector hook at its own seam.
|
||||
cell: { sessionId: id, session },
|
||||
binding,
|
||||
// Sources are bare observables; React binds selector hooks at its own seam.
|
||||
provideInfo: this.materializeProvideInfo(binding),
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
|
||||
private eligible(id: SessionId): boolean {
|
||||
return this.list.getSnapshot().byId[id] !== undefined
|
||||
}
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const { items, current, intent, phase } = this.manager.getListSnapshot()
|
||||
const { items, current, phase } = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
@@ -391,6 +513,7 @@ export class SessionsService {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
@@ -398,19 +521,22 @@ export class SessionsService {
|
||||
}
|
||||
}
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
if (intent?.sessionId === current) {
|
||||
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
||||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||||
if (current === undefined) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (current !== undefined && byId[current] !== undefined && persisted !== current) {
|
||||
} else if (byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
}
|
||||
this.list.set({ ids, byId, current, intent, phase })
|
||||
this.list.set({ ids, byId, current, phase })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
/** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
|
||||
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
||||
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
||||
void byId
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (byId[id] !== undefined) continue
|
||||
if (this.eligible(id)) continue
|
||||
if (id === this.watched) {
|
||||
this.deferredRemovals.add(id)
|
||||
continue
|
||||
@@ -421,12 +547,22 @@ export class SessionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
|
||||
/**
|
||||
* One teardown for the whole per-session axis (decision 12): the scope
|
||||
* fiber (cascading every actx-registered effect: input shell, slash
|
||||
* controller, popup, plugin stores, listeners), the session-keyed slot
|
||||
* stores, and the Session instance itself — the host session log is the
|
||||
* durable truth, a reopen lazily rebuilds and backfills via open().
|
||||
*/
|
||||
private dropScope(id: SessionId, record: ScopeRecord): void {
|
||||
void record.fiber.dispose()
|
||||
// Release the Session's dispatch point with the scope it belongs to (a
|
||||
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
||||
record.binding.session.unbindScope()
|
||||
// Optional lookup: slots and sessions are sibling services with no
|
||||
// declared dependency; a slots-less boot (object-layer tests) skips.
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
this.manager.drop(id)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
||||
@@ -436,8 +572,8 @@ export class SessionsService {
|
||||
* stage move sweeps first, so the set cannot contain the id the stage just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Still absent from the list? (A re-added id cancels the deferred teardown.)
|
||||
if (this.list.getSnapshot().byId[id] !== undefined) {
|
||||
// Eligible again? (A re-added id cancels the deferred teardown.)
|
||||
if (this.eligible(id)) {
|
||||
this.deferredRemovals.delete(id)
|
||||
continue
|
||||
}
|
||||
|
||||
590
packages/client/runtime/src/client/sessions/service.ts.orig
Normal file
590
packages/client/runtime/src/client/sessions/service.ts.orig
Normal file
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* SessionsService: root sessions service — list snapshot store (manager
|
||||
* projection; carries `current`, the persisted selection every
|
||||
* session-scoped surface keys off — migrated here from ui-layout per the
|
||||
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
|
||||
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
|
||||
* id), stable SessionBinding cache, ancestry walk.
|
||||
*
|
||||
* Scope lifecycle is stage-driven: a scope is minted lazily on first
|
||||
* resolution (pure — resolution has no side effects and is render-safe);
|
||||
* the event window and deferred teardown key off the STAGED session, which
|
||||
* follows `list.current` exactly. Staging is the open signal: the window
|
||||
* opens ⟺ the session is on stage (today the stage is `current`; the staged
|
||||
* state can widen to a multi-pane list later). A session leaving the list
|
||||
* tears its scope down immediately unless it is the staged one, whose scope
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase } from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
id: SessionId
|
||||
/** Latest durable log-backed title, absent until the host projects one. */
|
||||
title?: string
|
||||
/** Human-facing label: durable title, project basename, then session id. */
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). List surfaces hide blank
|
||||
* sessions; New Session reuses a blank one targeting the same workspace.
|
||||
* Filtering stays with the consumer — the store carries every row.
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Session list store shape. `current` rides the same snapshot (arbitrated:
|
||||
* the single useSessions standard hook reads list and selection together —
|
||||
* sidebar highlighting and SessionProvider share one fact source).
|
||||
*/
|
||||
export interface SessionListState {
|
||||
ids: SessionId[]
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
current: SessionId | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
}
|
||||
|
||||
/** Structured session-create failure. */
|
||||
export class SessionCreateError extends Error {
|
||||
override readonly name = 'SessionCreateError'
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: RpcError,
|
||||
readonly requestedSessionId: SessionId | undefined,
|
||||
) {
|
||||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
readonly sessionId: SessionId
|
||||
readonly session: Session
|
||||
readonly ctx: Context
|
||||
}
|
||||
|
||||
// Scope primitives live in ../agents/scope.ts (the client mirror of host
|
||||
// dsh-scope, keyed by Agent identity); re-exported here so existing
|
||||
// consumers keep their import site.
|
||||
export { scopeOf } from '../agents/scope.ts'
|
||||
|
||||
/**
|
||||
* Workspace display title of a session cwd: the path's last non-empty
|
||||
* segment (both separators accepted; trailing separators ignored), or ''
|
||||
* for separator-only paths — callers own their fallback (session id, raw
|
||||
* cwd, default-directory copy). The repo-wide single basename derivation —
|
||||
* every surface naming a workspace (picker rows, toggle labels, list titles)
|
||||
* calls this instead of re-splitting paths.
|
||||
* @param cwd - workspace directory path.
|
||||
* @returns basename title, or '' when no non-empty segment exists.
|
||||
*/
|
||||
export function workspaceTitleOf(cwd: string): string {
|
||||
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Display title projection: durable title, project directory basename, then
|
||||
* the raw id.
|
||||
*/
|
||||
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
|
||||
if (title !== undefined) return title
|
||||
if (cwd !== undefined && cwd !== '') {
|
||||
const base = workspaceTitleOf(cwd)
|
||||
if (base !== '') return base
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
interface ScopeRecord {
|
||||
fiber: Fiber
|
||||
ctx: Context
|
||||
binding: SessionBinding
|
||||
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
|
||||
provideInfo: SessionProvideInfo
|
||||
}
|
||||
|
||||
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
|
||||
export interface SessionProvideContribution {
|
||||
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
|
||||
hooks?: Record<string, HostObservable<unknown>>
|
||||
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
|
||||
props?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Static declaration plus per-session resolver for one standard-kit
|
||||
* contribution. The declared names let the renderer construct the same hook
|
||||
* and prop surface while no session is current.
|
||||
*/
|
||||
export interface SessionProvideDescriptor {
|
||||
/** Hook base names (`input` becomes `useInput`). */
|
||||
hooks?: readonly string[]
|
||||
/** Plain standard-prop names. */
|
||||
props?: readonly string[]
|
||||
/** Resolve every declared member for one definite session. */
|
||||
resolve(binding: SessionBinding): SessionProvideContribution
|
||||
}
|
||||
|
||||
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
|
||||
export class SessionsService {
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* SessionsService.open} / {@link SessionsService.clear}. Projection
|
||||
* validates it against the live list instead of destructively pruning, so a
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** Registered per-session standard-props providers, in registration order. */
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
* `current` without moving the stage, so reconnect re-pulls and removals
|
||||
* keep the staged scope's frozen view alive until the stage moves on).
|
||||
*/
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
this.manager.subscribe(() => { this.projectList() })
|
||||
// Stage follower: every current write (open() and projection alike)
|
||||
// re-evaluates staging, so startup restore (persisted selection validated
|
||||
// by the projection) and reconnect resurfacing open their window with no
|
||||
// dedicated code path. Safe to run synchronously inside the store notify:
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
// The runtime's own contribution comes first: useSession rides the same
|
||||
// provide channel every plugin uses (no renderer special case).
|
||||
this.providers.push({
|
||||
hooks: ['session'],
|
||||
resolve: binding => ({ hooks: { session: binding.session } }),
|
||||
})
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider: every session-scope slot
|
||||
* component receives the contributed members as standard props (`hooks`
|
||||
* sources become `use<Name>` selector hooks on the render side; `props`
|
||||
* spread verbatim). Contributions materialize lazily with the session's
|
||||
* scope record and die with it. Registration order is resolution order;
|
||||
* duplicate member names fail loud at materialization.
|
||||
* @param descriptor - static member roster plus per-session resolver.
|
||||
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
|
||||
*/
|
||||
provide(descriptor: SessionProvideDescriptor): () => void {
|
||||
this.providers.push(descriptor)
|
||||
// Scopes may already exist (boot order: the list lands and resolves
|
||||
// scopes before later plugins register) — their bundles must include
|
||||
// every provider by first render, so re-materialize on roster change.
|
||||
this.rematerializeProvideBundles()
|
||||
return () => {
|
||||
const at = this.providers.indexOf(descriptor)
|
||||
if (at >= 0) this.providers.splice(at, 1)
|
||||
this.rematerializeProvideBundles()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
|
||||
private rematerializeProvideBundles(): void {
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
|
||||
const hooks: Record<string, undefined> = {}
|
||||
const props: Record<string, undefined> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = undefined
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = undefined
|
||||
}
|
||||
}
|
||||
return { sessionId: undefined, hooks, props }
|
||||
}
|
||||
|
||||
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
|
||||
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
|
||||
const hooks: Record<string, HostObservable<unknown>> = {}
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const descriptor of this.providers) {
|
||||
const contribution = descriptor.resolve(binding)
|
||||
const contributedHooks = contribution.hooks ?? {}
|
||||
const contributedProps = contribution.props ?? {}
|
||||
for (const name of Object.keys(contributedHooks)) {
|
||||
if (!(descriptor.hooks ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared hook "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(contributedProps)) {
|
||||
if (!(descriptor.props ?? []).includes(name)) {
|
||||
throw new Error(`sessions.provide: undeclared prop "${name}"`)
|
||||
}
|
||||
}
|
||||
for (const name of descriptor.hooks ?? []) {
|
||||
const source = contributedHooks[name]
|
||||
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
|
||||
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
|
||||
hooks[name] = source
|
||||
}
|
||||
for (const name of descriptor.props ?? []) {
|
||||
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
|
||||
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
|
||||
props[name] = contributedProps[name]
|
||||
}
|
||||
}
|
||||
return { sessionId: binding.sessionId, hooks, props }
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere.
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
this.manager.select(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
* Wipes the persisted selection too — a reload stays on empty until the
|
||||
* user opens or starts a session. The staged scope keeps its frozen view
|
||||
* per the masked-gap contract until the next open() moves the stage.
|
||||
*/
|
||||
clear(): void {
|
||||
this.manager.clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started baseline pull.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mux stream envelope into the Session object layer.
|
||||
* @param envelope - validated mux stream envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
|
||||
this.manager.handleMuxEnvelope(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Host stream envelope into the Session object layer.
|
||||
* @param envelope - validated Host stream envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
|
||||
this.manager.handleHostEnvelope(envelope)
|
||||
}
|
||||
|
||||
/** Rebuild the Session baseline and every opened window after connection. */
|
||||
handleConnected(): void {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host. Resolution guarantee: by the time the
|
||||
* promise resolves, the created session is in the list store and
|
||||
* {@link SessionsService.binding} resolves it — callers (New Session
|
||||
* draft hand-off) may address the scope synchronously, without waiting a
|
||||
* notifier flush. The synchronous projection below makes this structural
|
||||
* rather than an accident of microtask ordering.
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @throws {SessionCreateError} with the requested id.
|
||||
*/
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
this.projectList()
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Agent-scoped context view (use-and-discard).
|
||||
* @param id - session id (the agent identity — 1:1 same axis).
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined {
|
||||
return this.resolve(id)?.ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Agent scope tag off a context. Service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
|
||||
* value import of the standalone helper would inline a second module
|
||||
* instance whose private tag Symbol never matches.
|
||||
* @param ctx - any client context.
|
||||
* @returns the session id, or undefined on root contexts.
|
||||
*/
|
||||
scopeOf(ctx: Context): SessionId | undefined {
|
||||
return scopeTagOf(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the business Session behind an Agent-scoped context — the one
|
||||
* hop every scoped consumer (event listeners, per-session controllers)
|
||||
* takes from ctx-space into object-space (the client mirror of host
|
||||
* `agent.session`). Same service-method seam as
|
||||
* {@link SessionsService.scopeOf}.
|
||||
* @param ctx - an Agent-scoped context.
|
||||
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
|
||||
*/
|
||||
sessionOf(ctx: Context): Session | undefined {
|
||||
const id = scopeTagOf(ctx)
|
||||
if (id === undefined) return undefined
|
||||
return this.scopes.get(id)?.binding.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stable session binding (scope-addressed assembly feed). Pure
|
||||
* resolution — no staging, no window side effects.
|
||||
* @param id - session id.
|
||||
* @returns binding, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
binding(id: SessionId): SessionBinding | undefined {
|
||||
return this.resolve(id)?.binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer standard-props bundle (SessionProvider's feed
|
||||
* through the renderer host; ctx never enters the render layer). Pure
|
||||
* resolution — render-safe: SessionProvider calls this during render, so no
|
||||
* staging, no window side effects (StrictMode double-invokes and concurrent
|
||||
* discarded passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns the provide info, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
return this.resolve(id as SessionId)?.provideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current-session-optional standard kit. Unknown or absent ids
|
||||
* return the static no-session projection rather than removing hook props.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns a definite or no-session provide bundle.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the stage to the list's current session: sweep teardowns deferred
|
||||
* behind the previous occupant and pull the new occupant's history window.
|
||||
* Staging IS the open signal — the window opens ⟺ the session is on stage
|
||||
* — and open() is idempotent (an in-flight or completed open no-ops; a
|
||||
* failed one retries the next time current is touched).
|
||||
*/
|
||||
private followCurrent(): void {
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const current = snapshot.current
|
||||
// A masked gap (current blanked while the selection's session is
|
||||
// transiently absent) holds the stage: tearing down on the gap would
|
||||
// destroy exactly the frozen scope the mask exists to preserve.
|
||||
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
|
||||
this.watched = current
|
||||
this.sweepDeferred()
|
||||
const record = this.resolve(current)
|
||||
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
|
||||
* validates and the projection masks absent selections), so resolve
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.binding.session.open()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb feed: walk parentId links inside the list store.
|
||||
* @param id - session id.
|
||||
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
|
||||
*/
|
||||
ancestry(id: SessionId): SessionSummary[] {
|
||||
const { byId } = this.list.getSnapshot()
|
||||
const chain: SessionSummary[] = []
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
const summary: SessionSummary | undefined = byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily mint the scope + binding for an eligible session. Eligibility and
|
||||
* prune share one predicate (decision 12): listed on the host — a scope is
|
||||
* born when its session enters the client's view (list mirror row from the
|
||||
* baseline pull, a create() echo, or the session-added frame) and dies with
|
||||
* the prune when the row leaves.
|
||||
*/
|
||||
private resolve(id: SessionId): ScopeRecord | undefined {
|
||||
const existing = this.scopes.get(id)
|
||||
if (existing !== undefined) return existing
|
||||
if (!this.eligible(id)) return undefined
|
||||
const { fiber, ctx } = createScope(this.rootCtx, id)
|
||||
const session = this.manager.get(id)
|
||||
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
|
||||
// mint and bind are one step so a live scope record implies a bound actx.
|
||||
session.bindScope(ctx)
|
||||
const binding: SessionBinding = { sessionId: id, session, ctx }
|
||||
const record: ScopeRecord = {
|
||||
fiber,
|
||||
ctx,
|
||||
binding,
|
||||
// Sources are bare observables; React binds selector hooks at its own seam.
|
||||
provideInfo: this.materializeProvideInfo(binding),
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
|
||||
private eligible(id: SessionId): boolean {
|
||||
return this.list.getSnapshot().byId[id] !== undefined
|
||||
}
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const { items, current, phase } = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
ids.push(entry.sessionId)
|
||||
byId[entry.sessionId] = {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
||||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||||
if (current === undefined) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
|
||||
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
|
||||
void byId
|
||||
for (const [id, record] of this.scopes) {
|
||||
if (this.eligible(id)) continue
|
||||
if (id === this.watched) {
|
||||
this.deferredRemovals.add(id)
|
||||
continue
|
||||
}
|
||||
this.scopes.delete(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One teardown for the whole per-session axis (decision 12): the scope
|
||||
* fiber (cascading every actx-registered effect: input shell, slash
|
||||
* controller, popup, plugin stores, listeners), the session-keyed slot
|
||||
* stores, and the Session instance itself — the host session log is the
|
||||
* durable truth, a reopen lazily rebuilds and backfills via open().
|
||||
*/
|
||||
private dropScope(id: SessionId, record: ScopeRecord): void {
|
||||
void record.fiber.dispose()
|
||||
// Release the Session's dispatch point with the scope it belongs to (a
|
||||
// surviving instance — the live Intent — rebinds when resolve re-mints).
|
||||
record.binding.session.unbindScope()
|
||||
// Optional lookup: slots and sessions are sibling services with no
|
||||
// declared dependency; a slots-less boot (object-layer tests) skips.
|
||||
this.rootCtx.get('slots')?.pruneStoreScope(id)
|
||||
this.manager.drop(id)
|
||||
}
|
||||
|
||||
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
|
||||
private sweepDeferred(): void {
|
||||
for (const id of [...this.deferredRemovals]) {
|
||||
/* v8 ignore next -- defensive: only the staged id ever defers, and every
|
||||
* stage move sweeps first, so the set cannot contain the id the stage just
|
||||
* moved to; kept as a guard against future extra sweep call sites. */
|
||||
if (id === this.watched) continue
|
||||
// Eligible again? (A re-added id cancels the deferred teardown.)
|
||||
if (this.eligible(id)) {
|
||||
this.deferredRemovals.delete(id)
|
||||
continue
|
||||
}
|
||||
const record = this.scopes.get(id)
|
||||
this.deferredRemovals.delete(id)
|
||||
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
|
||||
* together, so a deferred id always still owns its record; kept so a
|
||||
* future teardown path cannot double-dispose. */
|
||||
if (record !== undefined) {
|
||||
this.scopes.delete(id)
|
||||
this.dropScope(id, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, ModelTarget, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, SessionModels, ToolEventView, WorkspaceId,
|
||||
SessionId, SessionModels, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt,
|
||||
ModelSelectionSnapshot, PromptError, RunningToolCall, SessionIntentSnapshot,
|
||||
SessionIntentTarget,
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
|
||||
ModelSelectionSnapshot, PromptError, QueuedMessage, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
@@ -24,10 +24,37 @@ import { PartialAccumulator } from './partial.ts'
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** Optional frontend Intent and publication observer for a Session object. */
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
intent?: { target: SessionIntentTarget; prompt: string }
|
||||
onPublished?(session: Session): void
|
||||
/**
|
||||
* First ACCEPTED prompt on a blank session (fires at most once, on the
|
||||
* prompt RPC's success response): the manager mirrors the blank→false flip
|
||||
* into its list row so the session surfaces without waiting for a host
|
||||
* frame. Acceptance is the flip point because it proves the user message
|
||||
* is in the host log; a rejected first prompt keeps the session blank
|
||||
* (hidden, still reusable by connectWorkspace).
|
||||
*/
|
||||
onEngaged?(session: Session): void
|
||||
}
|
||||
|
||||
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||
const QUEUE_PREVIEW_CHARS = 200
|
||||
|
||||
/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */
|
||||
interface QueuedEntry {
|
||||
row: QueuedMessage
|
||||
steering: boolean
|
||||
/** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */
|
||||
sourceJson: string
|
||||
}
|
||||
|
||||
/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */
|
||||
function queuePreviewOf(content: readonly ContentBlock[]): string {
|
||||
const flat = content
|
||||
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
|
||||
.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const chars = Array.from(flat)
|
||||
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,8 +92,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
|
||||
private pendingRev = 0
|
||||
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
|
||||
/** Inbox mirror (session/queued frames + mux-open baseline). Queue frames never hit history,
|
||||
* so this is stream-only state: reconnect clears it and the fresh baseline re-populates. */
|
||||
private queued: QueuedEntry[] = []
|
||||
private queueRev = 0
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
private running = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
@@ -74,12 +111,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* engaging edge of the phase machine (see ComposerPhase).
|
||||
*/
|
||||
private promptAttempted = false
|
||||
/** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */
|
||||
private blankBit = false
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private intent: SessionIntentSnapshot | null
|
||||
private pendingPrompt: PendingPrompt | null
|
||||
private intentGeneration = 0
|
||||
private published: boolean
|
||||
private lastAgentError: string | null = null
|
||||
private modelSelection: ModelSelectionSnapshot = {
|
||||
current: null,
|
||||
@@ -108,27 +143,46 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
/**
|
||||
* Agent-scoped cordis context, bound once by SessionsService when it
|
||||
* mints the scope (the client mirror of the host Agent's loopCtx). The
|
||||
* Session dispatches its own scoped events through it; undefined means
|
||||
* unbound (bare object-layer construction) or already pruned — both skip
|
||||
* dispatch-dependent behavior rather than fail.
|
||||
*/
|
||||
private actx: Context | undefined
|
||||
|
||||
/**
|
||||
* @param sessionId - stable identity shared by the frontend Intent and Host entity.
|
||||
* @param sessionId - Host session identity (client sessions are always Host-born).
|
||||
* @param api - shared wire client.
|
||||
* @param options - optional frontend-only initial state and publication observer.
|
||||
* @param options - optional manager-owned state observers.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.intent = options.intent === undefined
|
||||
? null
|
||||
: { target: options.intent.target, phase: 'ready' }
|
||||
this.pendingPrompt = options.intent === undefined
|
||||
? null
|
||||
: { text: options.intent.prompt, phase: 'editing', retry: 'send' }
|
||||
this.published = options.intent === undefined
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the Agent-scoped context minted by SessionsService (single write;
|
||||
* a second bind is a wiring error and throws). Direction stays one-way at
|
||||
* the seam: consumers still reach the Session via `sessions.sessionOf`,
|
||||
* while the Session holds its own dispatch point (host Agent.loopCtx
|
||||
* mirror).
|
||||
* @param actx - the agent's scoped context.
|
||||
*/
|
||||
bindScope(actx: Context): void {
|
||||
if (this.actx !== undefined) throw new Error(`session ${this.sessionId} already has a bound scope`)
|
||||
this.actx = actx
|
||||
}
|
||||
|
||||
/** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */
|
||||
unbindScope(): void {
|
||||
this.actx = undefined
|
||||
}
|
||||
|
||||
// ---- Operations ----
|
||||
|
||||
/**
|
||||
@@ -154,64 +208,22 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (!result.ok) {
|
||||
this.promptError = { op: 'send', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt has logged
|
||||
// its user/message on the host (events.length > 0 is fact, not
|
||||
// optimism), while a rejected first prompt must keep the session blank
|
||||
// — the client-side blank mirror only ever lowers, so flipping early on
|
||||
// a failure would surface the session forever and strip its
|
||||
// connectWorkspace reuse eligibility against the host's authority.
|
||||
if (this.blankBit) {
|
||||
this.blankBit = false
|
||||
this.options.onEngaged?.(this)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this Session's retained prompt while it remains editable.
|
||||
* @param text - exact controlled value of this Session's retained prompt.
|
||||
*/
|
||||
updatePendingPrompt(text: string): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending') return
|
||||
this.pendingPrompt = { ...pending, text }
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect this frontend Session to a real Workspace and flush its retained prompt.
|
||||
* @param workspaceId - real Workspace target.
|
||||
*/
|
||||
connect(workspaceId: WorkspaceId): void {
|
||||
const intent = this.intent
|
||||
const pending = this.pendingPrompt
|
||||
if (intent === null || intent.phase === 'connecting' || pending === null || pending.text.trim() === '') return
|
||||
const connecting: SessionIntentSnapshot = {
|
||||
target: { kind: 'workspace', workspaceId },
|
||||
phase: 'connecting',
|
||||
}
|
||||
const queued: PendingPrompt = {
|
||||
...pending,
|
||||
phase: 'sending',
|
||||
retry: 'connect',
|
||||
workspaceId,
|
||||
}
|
||||
delete queued.error
|
||||
this.intent = connecting
|
||||
this.pendingPrompt = queued
|
||||
this.notifier.notifyNow()
|
||||
void this.flushPendingPrompt()
|
||||
}
|
||||
|
||||
/** Stop a superseded frontend Intent from automatically sending after publication. */
|
||||
abandonIntent(): void {
|
||||
if (this.intent === null) return
|
||||
this.intentGeneration += 1
|
||||
}
|
||||
|
||||
/** Retry this Session's retained prompt from its failed prerequisite. */
|
||||
retryPendingPrompt(): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending' || pending.text.trim() === '') return
|
||||
const sending: PendingPrompt = { ...pending, phase: 'sending' }
|
||||
delete sending.error
|
||||
this.pendingPrompt = sending
|
||||
this.promptError = null
|
||||
this.notifier.markDirty()
|
||||
void this.flushPendingPrompt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
|
||||
* @returns the cancel result.
|
||||
@@ -381,6 +393,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* in-flight open first — its history request rode the dead connection and must not settle
|
||||
* the fresh generation into 'error' (audit S4). */
|
||||
async resync(): Promise<void> {
|
||||
// The queue mirror is NOT cleared here: onConnected (which drives resync)
|
||||
// races the mux frames — the fresh generation's baseline may have landed
|
||||
// already, and the host never resends it. The mirror re-baselines on the
|
||||
// session/subscribed frame instead (same stream as the queue snapshot
|
||||
// that follows it, so ordering is guaranteed).
|
||||
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
|
||||
this.openGeneration++
|
||||
this.openPromise = null
|
||||
@@ -429,12 +446,35 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
|
||||
switch (frame.type) {
|
||||
case 'session/event': {
|
||||
this.retireQueued(frame.event)
|
||||
this.acceptLiveEvent(frame.event, frame.view)
|
||||
return
|
||||
}
|
||||
case 'session/queued': {
|
||||
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
|
||||
// provisional-echo reconciliation key); otherwise the frame envelope id.
|
||||
const key = 'rpcId' in frame.source ? String(frame.source.rpcId) : `f:${rpcId}`
|
||||
this.queued.push({
|
||||
row: { key, preview: queuePreviewOf(frame.content) },
|
||||
steering: frame.steering,
|
||||
sourceJson: JSON.stringify(frame.source),
|
||||
})
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'session/subscribed': {
|
||||
this.subscribedLastSeq = frame.lastSeq
|
||||
return // pure baseline bookkeeping, no visible change
|
||||
// New mux-generation baseline: the host pushes this session's queue
|
||||
// snapshot AFTER the subscribed frame on the same stream, so the
|
||||
// stale mirror clears here — race-free against onConnected/resync
|
||||
// timing (clearing there could wipe a baseline that already landed).
|
||||
if (this.queued.length > 0) {
|
||||
this.queued = []
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'approval/requested': {
|
||||
const { type: _type, sessionId: _sid, ...payload } = frame
|
||||
@@ -471,14 +511,38 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* @param running - the new running state.
|
||||
*/
|
||||
handleRunning(running: boolean): void {
|
||||
// Leave-running sweep (host queuedMirror precedent): discard paths (cancel,
|
||||
// terminal steering drop) have no per-entry frame, so ANY not-running signal
|
||||
// with a nonempty mirror clears it — checked before the equality return so a
|
||||
// stale replay on an already-idle session still sweeps.
|
||||
if (!running && this.queued.length > 0) {
|
||||
this.queued = []
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
// Turn-start conversion: a blank session never runs, so the first
|
||||
// running:true proves another端's first message landed (设计稿 2.2).
|
||||
if (running && this.blankBit) {
|
||||
this.blankBit = false
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
if (this.running === running) return
|
||||
this.running = running
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Mark that Host publication is known without resolving an uncertain local create response. */
|
||||
handlePublished(): void {
|
||||
this.markPublished()
|
||||
/**
|
||||
* Blank-bit relay from the authoritative summary source (list baseline and
|
||||
* the session-added frame). Monotone: once any signal (local first send,
|
||||
* running flip, an earlier summary) cleared it, a stale true never
|
||||
* re-blanks.
|
||||
* @param blank - the summary's derived empty-log bit.
|
||||
*/
|
||||
handleBlank(blank: boolean): void {
|
||||
if (blank === this.blankBit) return
|
||||
if (blank && (this.promptAttempted || this.running)) return
|
||||
this.blankBit = blank
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
|
||||
@@ -514,112 +578,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.pendingRev++
|
||||
}
|
||||
|
||||
/** Advance the retained prompt through Session attachment and submission. */
|
||||
private async flushPendingPrompt(): Promise<void> {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending?.phase === 'sending') {
|
||||
const ready = pending.retry === 'connect'
|
||||
? await this.attachPendingPrompt(pending)
|
||||
: pending
|
||||
if (ready !== null) await this.sendPendingPrompt(ready)
|
||||
}
|
||||
}
|
||||
|
||||
/** Complete the Host Session prerequisite and return the prompt's send step. */
|
||||
private async attachPendingPrompt(pending: PendingPrompt): Promise<PendingPrompt | null> {
|
||||
const workspaceId = pending.workspaceId
|
||||
if (workspaceId === undefined) throw new Error('a Session attachment requires a Workspace id')
|
||||
const originIntent = this.intent
|
||||
const originGeneration = this.intentGeneration
|
||||
let result: RpcResult<{ sessionId: SessionId }>
|
||||
try {
|
||||
result = (await this.api.sessions.create({ sessionId: this.sessionId, workspaceId })).result
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
let ready: PendingPrompt | null = null
|
||||
if (result.ok) {
|
||||
ready = this.completePendingAttachment(pending, originIntent, originGeneration)
|
||||
} else {
|
||||
this.failPendingAttachment(pending, originIntent, originGeneration, result.error)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return ready
|
||||
}
|
||||
|
||||
/** Move a published Session to the send step unless its page intent was superseded. */
|
||||
private completePendingAttachment(
|
||||
pending: PendingPrompt,
|
||||
originIntent: SessionIntentSnapshot | null,
|
||||
originGeneration: number,
|
||||
): PendingPrompt | null {
|
||||
this.markPublished()
|
||||
this.intent = null
|
||||
this.promptAttempted = true
|
||||
const superseded = originIntent !== null && originGeneration !== this.intentGeneration
|
||||
const next: PendingPrompt = {
|
||||
...pending,
|
||||
phase: superseded ? 'failed' : 'sending',
|
||||
retry: 'send',
|
||||
...(superseded ? { error: 'Message was not sent because you navigated away.' } : {}),
|
||||
}
|
||||
if (!superseded) delete next.error
|
||||
this.pendingPrompt = next
|
||||
return superseded ? null : next
|
||||
}
|
||||
|
||||
/** Retain the prompt at the failed attachment step that owns the retry. */
|
||||
private failPendingAttachment(
|
||||
pending: PendingPrompt,
|
||||
originIntent: SessionIntentSnapshot | null,
|
||||
originGeneration: number,
|
||||
error: RpcError,
|
||||
): void {
|
||||
const partiallyPublished = error.code === 'workspace-attach-failed'
|
||||
if (partiallyPublished) {
|
||||
this.markPublished()
|
||||
this.intent = null
|
||||
this.promptAttempted = true
|
||||
}
|
||||
const activeIntent = !partiallyPublished
|
||||
&& originIntent !== null
|
||||
&& originGeneration === this.intentGeneration
|
||||
&& this.intent === originIntent
|
||||
if (activeIntent) {
|
||||
this.intent = {
|
||||
target: originIntent.target,
|
||||
phase: 'ready',
|
||||
error: { step: 'session', message: rpcErrorMessage(error) },
|
||||
}
|
||||
this.pendingPrompt = { ...pending, phase: 'editing' }
|
||||
}
|
||||
if (!activeIntent && (partiallyPublished || originIntent === null) && this.pendingPrompt === pending) {
|
||||
this.pendingPrompt = { ...pending, phase: 'failed', error: rpcErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit the retained prompt and keep it only when Host rejects the send. */
|
||||
private async sendPendingPrompt(pending: PendingPrompt): Promise<void> {
|
||||
const result = await this.prompt([{ type: 'text', text: pending.text.trim() }], 'queue')
|
||||
if (this.pendingPrompt === pending) {
|
||||
this.pendingPrompt = result.ok
|
||||
? null
|
||||
: {
|
||||
...pending,
|
||||
retry: 'send',
|
||||
phase: 'failed',
|
||||
error: rpcErrorMessage(result.error),
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private markPublished(): void {
|
||||
if (this.published) return
|
||||
this.published = true
|
||||
this.options.onPublished?.(this)
|
||||
}
|
||||
|
||||
/** @param generation - openGeneration at launch; every await re-checks it and a stale pass
|
||||
* drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
@@ -760,9 +718,89 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered
|
||||
* turn/start claims the oldest non-steering entry; a steering/message drains the oldest
|
||||
* steering entry with the same source (loop-authored steering matches nothing and drops none). */
|
||||
private retireQueued(event: SessionEvent): void {
|
||||
if (this.queued.length === 0) return
|
||||
let index = -1
|
||||
if (event.type === 'turn/start') {
|
||||
if (event.data.trigger.kind !== 'message') return
|
||||
index = this.queued.findIndex(entry => !entry.steering)
|
||||
} else if (event.type === 'steering/message') {
|
||||
const source = JSON.stringify(event.data.source)
|
||||
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (index < 0) return
|
||||
this.queued.splice(index, 1)
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Per-event side effects (right column of the §A.9 dispatch table):
|
||||
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
|
||||
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
|
||||
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
|
||||
// the host-side dsh-tools plugin whose types cannot enter the client
|
||||
// program (its host Context merges collide with the client's), so this
|
||||
// wire consumer narrows them structurally — the same posture as every
|
||||
// other cross-wire event payload.
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
// A started sub-dispatch enters the index as a RunningToolCall — the
|
||||
// exact shape a native in-flight call renders from — under its parent
|
||||
// run_code callId; it never joins the surface flow.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const running: CodeSubCall = {
|
||||
callId: data.subCallId, name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0, step: 0, time: event.time, callView: null,
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
this.codeDispatches.set(data.parentCallId, [...siblings, running])
|
||||
this.dispatchesRev++
|
||||
return
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
// Settlement replaces the running entry in place (same array position,
|
||||
// so parallel sub-calls keep their start order) with the
|
||||
// ToolResultNode form; a settle with no observed start (history window
|
||||
// cut mid-pair, or a pre-start-event log) appends directly.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
// Duration source: the paired start's time when observed; null =
|
||||
// unknown (settle-only window), matching the native tool-result
|
||||
// contract so views never present a fabricated zero duration.
|
||||
callTime: started === undefined ? null : started.time,
|
||||
content: data.content, isError: data.isError,
|
||||
callView: null, resultView: null,
|
||||
}
|
||||
this.codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
|
||||
)
|
||||
this.dispatchesRev++
|
||||
return
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
@@ -842,6 +880,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.callsRev++
|
||||
this.frozenNodes = []
|
||||
this.frozenRev++
|
||||
this.codeDispatches = new Map()
|
||||
this.dispatchesRev++
|
||||
for (let i = 0; i < this.events.length; i++) {
|
||||
const event = this.events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
@@ -874,6 +914,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
|
||||
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
|
||||
}
|
||||
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
|
||||
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
|
||||
}
|
||||
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
|
||||
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
|
||||
}
|
||||
const partial = this.partial?.toPartial() ?? null
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
@@ -882,6 +928,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
pending: this.pendingCache.value,
|
||||
codeDispatches: this.dispatchesCache.value,
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
composerPhase: derivePhase(
|
||||
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
@@ -893,18 +941,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
hasMore: this.hasMore,
|
||||
loadingOlder: this.loadingOlder,
|
||||
promptError: this.promptError,
|
||||
intent: this.intent,
|
||||
pendingPrompt: this.pendingPrompt,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
modelSelection: this.modelSelection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rpcErrorMessage(error: RpcError): string {
|
||||
return `${error.code}: ${error.message}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The composerPhase judgment — the single site that knows the predicate
|
||||
* (consumers switch on the result, never re-derive). Monotone per session
|
||||
|
||||
@@ -235,7 +235,7 @@ export class SlotsService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build once after both object-layer services mount; session cells still resolve lazily. */
|
||||
/** Build once after both object-layer services mount; per-session provide bundles still resolve lazily. */
|
||||
private hostFace(): SlotRendererHost {
|
||||
if (this._host !== undefined) return this._host
|
||||
const sessions = this.ctx.get('sessions')
|
||||
@@ -264,7 +264,8 @@ export class SlotsService extends Service {
|
||||
sessions: {
|
||||
list: sessions.list,
|
||||
current,
|
||||
cell: id => sessions.cell(id),
|
||||
provideInfo: id => sessions.provideInfo(id),
|
||||
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
|
||||
},
|
||||
workspaces: { list: workspaces.list },
|
||||
}
|
||||
@@ -275,13 +276,13 @@ export class SlotsService extends Service {
|
||||
private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
|
||||
const record = this._stores.get(handle)
|
||||
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
|
||||
const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY
|
||||
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
|
||||
const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : sessionId
|
||||
if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`)
|
||||
let instance = record.instances.get(key)
|
||||
if (instance === undefined) {
|
||||
// Session instances get the scope key (the engine suffixes the persist
|
||||
// key per session); root instances stay keyless.
|
||||
instance = record.scope === 'session' ? handle.create(key) : handle.create()
|
||||
instance = record.scope === 'root' ? handle.create() : handle.create(key)
|
||||
record.instances.set(key, instance)
|
||||
}
|
||||
return instance
|
||||
|
||||
@@ -6,11 +6,7 @@ import type {
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
import {
|
||||
Workspace, type WorkspaceCreateInput, type WorkspaceIntentSnapshot,
|
||||
} from './workspace.ts'
|
||||
|
||||
export type { WorkspaceIntentSnapshot } from './workspace.ts'
|
||||
import { Workspace, type WorkspaceCreateInput } from './workspace.ts'
|
||||
|
||||
/** Monotone workspace-list arrival lifecycle. */
|
||||
export type WorkspaceListPhase = 'pending' | 'ready'
|
||||
@@ -18,8 +14,6 @@ export type WorkspaceListPhase = 'pending' | 'ready'
|
||||
/** Immutable workspace-list snapshot. */
|
||||
export interface WorkspaceListSnapshot {
|
||||
items: readonly WorkspaceView[]
|
||||
/** The sole page-local Workspace intent; never persisted or sent over the Host stream. */
|
||||
intent: WorkspaceIntentSnapshot | undefined
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
phase: WorkspaceListPhase
|
||||
error: RpcError | null
|
||||
@@ -28,7 +22,6 @@ export interface WorkspaceListSnapshot {
|
||||
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
|
||||
export class WorkspaceManager {
|
||||
private items: Workspace[] = []
|
||||
private intent: Workspace | undefined
|
||||
private itemViewsSource: readonly Workspace[] | null = null
|
||||
private itemViewsCache: readonly WorkspaceView[] = []
|
||||
private state: WorkspaceListSnapshot['state'] = 'idle'
|
||||
@@ -46,44 +39,6 @@ export class WorkspaceManager {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the current client-local Workspace intent object.
|
||||
* @param name - directory/display name used if the intent is materialized.
|
||||
* @returns the new intent snapshot.
|
||||
*/
|
||||
startIntent(name = 'workspace'): WorkspaceIntentSnapshot {
|
||||
this.intent = new Workspace(this.api, { name })
|
||||
this.notifier.notifyNow()
|
||||
return this.intent.getSnapshot().intent as WorkspaceIntentSnapshot
|
||||
}
|
||||
|
||||
/** Discard the current client-local Workspace intent. */
|
||||
discardIntent(): void {
|
||||
if (this.intent === undefined) return
|
||||
this.intent = undefined
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the current Workspace intent through the ordinary Host create seam.
|
||||
* A superseded intent is never cleared by an older completion.
|
||||
* @returns the Host create result, or undefined when no intent exists.
|
||||
*/
|
||||
async materializeIntent(): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }> | undefined> {
|
||||
const intent = this.intent
|
||||
if (intent?.getSnapshot().intent?.phase !== 'ready') return undefined
|
||||
const completion = intent.materialize()
|
||||
if (completion === undefined) return undefined
|
||||
this.notifier.notifyNow()
|
||||
const result = await completion
|
||||
if (result.ok) {
|
||||
this.upsert(result.value.workspace, intent)
|
||||
if (this.intent === intent) this.intent = undefined
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from workspace.list. The first successful response establishes
|
||||
* Host order; later responses update membership and values without moving
|
||||
@@ -212,7 +167,6 @@ export class WorkspaceManager {
|
||||
private buildSnapshot(): WorkspaceListSnapshot {
|
||||
return {
|
||||
items: this.itemViews(),
|
||||
intent: this.intent?.getSnapshot().intent,
|
||||
state: this.state,
|
||||
phase: this.phase,
|
||||
error: this.error,
|
||||
|
||||
@@ -7,13 +7,11 @@ import type {
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionsService } from '../sessions/service.ts'
|
||||
import { WorkspaceManager, type WorkspaceIntentSnapshot, type WorkspaceListPhase } from './manager.ts'
|
||||
import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
|
||||
|
||||
/** Workspace list plus the two-baseline readiness and default-target projection. */
|
||||
export interface WorkspaceListState {
|
||||
items: readonly WorkspaceView[]
|
||||
/** Sole client-local Workspace projection; its state remains owned by Workspace. */
|
||||
intent: WorkspaceIntentSnapshot | undefined
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
phase: WorkspaceListPhase
|
||||
error: RpcError | null
|
||||
@@ -29,64 +27,128 @@ export class WorkspacesService {
|
||||
readonly list: SnapshotStore<WorkspaceListState>
|
||||
/** Workspace baseline and frame owner. */
|
||||
private readonly manager: WorkspaceManager
|
||||
private initialSessionResolved = false
|
||||
private composingIntent = false
|
||||
/** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */
|
||||
private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
|
||||
/** Guards the runtime-owned one-shot initial-selection subscription. */
|
||||
private initialSelectionStarted = false
|
||||
|
||||
/**
|
||||
* @param ctx - client root context.
|
||||
* @param api - shared wire client.
|
||||
* @param sessions - lower-level Session service used for recency and cross-domain intent orchestration.
|
||||
* @param sessions - lower-level Session service used for recency and blank-session reuse.
|
||||
*/
|
||||
constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) {
|
||||
this.manager = new WorkspaceManager(api)
|
||||
this.list = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'pending', error: null,
|
||||
items: [], state: 'idle', phase: 'pending', error: null,
|
||||
baselinesReady: false, recentWorkspaceId: undefined,
|
||||
})
|
||||
this.manager.subscribe(() => { if (!this.composingIntent) this.project() })
|
||||
this.sessions.list.subscribe(() => { if (!this.composingIntent) this.project() })
|
||||
this.manager.subscribe(() => { this.project() })
|
||||
this.sessions.list.subscribe(() => { this.project() })
|
||||
ctx.reflect.provide('workspaces', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the sole Session intent, resolving the default Workspace here.
|
||||
* @param workspaceId - optional explicit real Workspace target.
|
||||
* @param prompt - optional prompt retained while retargeting.
|
||||
* Resolve the session a New Session flow lands in once this Workspace is
|
||||
* chosen: reuse the workspace's existing blank session when one is in the
|
||||
* list mirror, else create a fresh one on the host (`session.create` births
|
||||
* the full Session+Agent — the client holds no intermediate state). The
|
||||
* caller owns navigation: take the returned id to `sessions.open`.
|
||||
* Resolution guarantee (both arms): the returned id is already in the list
|
||||
* store and `sessions.binding(id)` resolves synchronously — draft hand-off
|
||||
* may write the new scope's machine before opening.
|
||||
* @param workspaceId - chosen Workspace (must be in the workspace list).
|
||||
* @returns the reused or newly created session id.
|
||||
*/
|
||||
startSession(workspaceId?: WorkspaceId, prompt = ''): void {
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const resolved = workspaceId ?? snapshot.recentWorkspaceId ?? snapshot.items[0]?.workspaceId
|
||||
this.composingIntent = true
|
||||
try {
|
||||
if (resolved === undefined) {
|
||||
this.manager.startIntent()
|
||||
this.sessions.startIntent({ kind: 'workspace-intent' }, prompt)
|
||||
} else {
|
||||
this.manager.discardIntent()
|
||||
this.sessions.startIntent({ kind: 'workspace', workspaceId: resolved }, prompt)
|
||||
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
|
||||
const workspace = this.list.getSnapshot().items.find(item => item.workspaceId === workspaceId)
|
||||
if (workspace === undefined) throw new Error(`workspaces.connectWorkspace: unknown workspace ${workspaceId}`)
|
||||
// Coalesce concurrent connects: a create's summary lands without cwd
|
||||
// until the host frame arrives, so a second call inside that window
|
||||
// would miss the reuse scan and mint another hidden blank session.
|
||||
const inflight = this.connecting.get(workspaceId)
|
||||
if (inflight !== undefined) return inflight
|
||||
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
|
||||
// canon; summary cwd is the session header passthrough of the same canon).
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
for (const id of sessions.ids) {
|
||||
const summary = sessions.byId[id]
|
||||
if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id
|
||||
}
|
||||
const attempt = this.sessions.create({ workspaceId })
|
||||
.finally(() => { this.connecting.delete(workspaceId) })
|
||||
this.connecting.set(workspaceId, attempt)
|
||||
return attempt
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the first complete Workspace/Session baseline and select a default
|
||||
* session exactly once. A restored current session wins; otherwise the most
|
||||
* recent Workspace is connected (reusing or creating its blank session).
|
||||
* Later explicit clears stay cleared instead of retriggering this startup
|
||||
* policy. A failed connect may retry on the next baseline projection.
|
||||
* @returns disposer for the baseline subscription; late work cannot navigate after disposal.
|
||||
*/
|
||||
startInitialSelection(): () => void {
|
||||
if (this.initialSelectionStarted) {
|
||||
throw new Error('workspaces.startInitialSelection: already started')
|
||||
}
|
||||
this.initialSelectionStarted = true
|
||||
let state: 'waiting' | 'connecting' | 'done' = 'waiting'
|
||||
let disposed = false
|
||||
const reconcile = (): void => {
|
||||
if (disposed || state !== 'waiting') return
|
||||
const workspace = this.list.getSnapshot()
|
||||
if (!workspace.baselinesReady) return
|
||||
const current = this.sessions.list.getSnapshot().current
|
||||
const target = workspace.recentWorkspaceId
|
||||
if (current !== undefined || target === undefined) {
|
||||
state = 'done'
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
this.composingIntent = false
|
||||
this.project()
|
||||
state = 'connecting'
|
||||
void this.connectWorkspace(target).then(
|
||||
(sessionId) => {
|
||||
if (disposed) return
|
||||
if (this.sessions.list.getSnapshot().current === undefined) {
|
||||
this.sessions.open(sessionId)
|
||||
}
|
||||
state = 'done'
|
||||
},
|
||||
(reason: unknown) => {
|
||||
if (disposed) return
|
||||
state = 'waiting'
|
||||
console.warn('initial workspace selection failed:', reason)
|
||||
},
|
||||
)
|
||||
}
|
||||
const unsubscribe = this.list.subscribe(reconcile)
|
||||
reconcile()
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/** Connect the current frontend Workspace and Session, then flush the Session-owned prompt. */
|
||||
sendSession(): void {
|
||||
const session = this.sessions.intent()
|
||||
const target = session?.getSnapshot().intent?.target
|
||||
if (session === undefined || target === undefined) return
|
||||
if (target.kind === 'workspace') {
|
||||
session.connect(target.workspaceId)
|
||||
/**
|
||||
* The shared New Session action behind the shell entry points (sidebar
|
||||
* button, workspace browser): resolve the target Workspace — explicit wins,
|
||||
* else the recent-Workspace projection — connect its blank session and
|
||||
* navigate there; with no Workspace at all, clear the selection into the
|
||||
* New Session view state. Connect failures are non-fatal (console
|
||||
* diagnostics; the current view stays usable).
|
||||
* @param workspaceId - explicit target Workspace for scoped actions.
|
||||
*/
|
||||
startSession(workspaceId?: WorkspaceId): void {
|
||||
const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId
|
||||
if (target === undefined) {
|
||||
this.sessions.clear()
|
||||
return
|
||||
}
|
||||
if (session.getSnapshot().pendingPrompt?.text.trim() === '') return
|
||||
void this.manager.materializeIntent().then((result) => {
|
||||
if (this.sessions.intent() !== session) return
|
||||
if (result?.ok) {
|
||||
session.connect(result.value.workspace.workspaceId)
|
||||
}
|
||||
})
|
||||
void this.connectWorkspace(target).then(
|
||||
(sessionId) => { this.sessions.open(sessionId) },
|
||||
(reason: unknown) => { console.warn('new session failed:', reason) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,20 +215,15 @@ export class WorkspacesService {
|
||||
private project(): void {
|
||||
const workspace = this.manager.getSnapshot()
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
if (workspace.intent !== undefined && sessions.intent?.target.kind !== 'workspace-intent') {
|
||||
this.manager.discardIntent()
|
||||
return
|
||||
}
|
||||
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
|
||||
this.list.set({
|
||||
...workspace,
|
||||
items: workspace.items,
|
||||
state: workspace.state,
|
||||
phase: workspace.phase,
|
||||
error: workspace.error,
|
||||
baselinesReady,
|
||||
recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined,
|
||||
})
|
||||
if (!this.initialSessionResolved && baselinesReady) {
|
||||
this.initialSessionResolved = true
|
||||
if (sessions.current === undefined && sessions.intent === undefined) this.startSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
import type { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
@@ -33,6 +35,10 @@ async function mount(): Promise<Bench> {
|
||||
return bench
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 12; i++) await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('runtime client apply', () => {
|
||||
it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => {
|
||||
const bench = await mount()
|
||||
@@ -50,7 +56,7 @@ describe('runtime client apply', () => {
|
||||
// Frame sinks reach the object layer: a host session-added lands in the list store.
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r1' as never,
|
||||
payload: { type: 'host/session-added', sessionId: 's-new' } as never,
|
||||
payload: { type: 'host/session-added', blank: true, sessionId: 's-new' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
|
||||
@@ -71,6 +77,31 @@ describe('runtime client apply', () => {
|
||||
bench.sinks?.onConnected?.()
|
||||
})
|
||||
|
||||
it('selects the recent Workspace once when the first baselines have no current session', async () => {
|
||||
const bench = await mount()
|
||||
bench.api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [{
|
||||
workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}] as never[],
|
||||
}))
|
||||
bench.api.onList = () => Promise.resolve(ok({ items: [] }))
|
||||
|
||||
bench.sinks?.onConnected?.()
|
||||
await flushMicrotasks()
|
||||
|
||||
const sessions = bench.ctx.get('sessions') as SessionsService
|
||||
const workspaces = bench.ctx.get('workspaces') as WorkspacesService
|
||||
expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }])
|
||||
expect(sessions.list.getSnapshot().current).toBe('fk-new')
|
||||
|
||||
sessions.clear()
|
||||
await workspaces.refresh()
|
||||
await flushMicrotasks()
|
||||
expect(sessions.list.getSnapshot().current).toBeUndefined()
|
||||
expect(bench.api.callsOf('session.create')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stops the stream loop when the plugin fiber unloads', async () => {
|
||||
const bench = await mount()
|
||||
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))
|
||||
|
||||
@@ -26,6 +26,16 @@ export const ev = {
|
||||
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
|
||||
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
|
||||
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
|
||||
}),
|
||||
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch',
|
||||
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
|
||||
}),
|
||||
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'step/end', data: { turn, step } }),
|
||||
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
ClientResponse, HostFrame, IApiClient, ModelTarget, MuxFrame, RpcError, RpcReceipt,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, WorkspaceId, WorkspaceView,
|
||||
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
|
||||
ModelTarget, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -122,6 +123,25 @@ export class FakeApiClient implements IApiClient {
|
||||
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
|
||||
}
|
||||
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program requires-bearing catalogs and dual-address
|
||||
// skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connecti
|
||||
import { flattenLineage } from '../src/client/sessions/lineage.ts'
|
||||
|
||||
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
|
||||
sessionId: id as SessionId, updatedAt, running: false,
|
||||
sessionId: id as SessionId, updatedAt, running: false, blank: false,
|
||||
...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}),
|
||||
})
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ import { entries, plainTurn } from './event-script.ts'
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
const S2 = 'fk-m2' as SessionId
|
||||
|
||||
function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; parentSessionId: SessionId }> = {}) {
|
||||
return { sessionId, updatedAt: 100, running: false, ...over }
|
||||
type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }>
|
||||
|
||||
function summary(sessionId: SessionId, over: SummaryOver = {}) {
|
||||
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
|
||||
}
|
||||
|
||||
describe('instances', () => {
|
||||
@@ -81,7 +83,7 @@ describe('list lifecycle', () => {
|
||||
const hydration = manager.refreshList()
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'during-first' as never,
|
||||
payload: { type: 'host/session-added', sessionId: S2 },
|
||||
payload: { type: 'host/session-added', blank: true, sessionId: S2 },
|
||||
})
|
||||
first.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
await hydration
|
||||
@@ -157,7 +159,7 @@ describe('list lifecycle', () => {
|
||||
expect(titled.items[1]?.title).toBeUndefined()
|
||||
|
||||
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -196,8 +198,8 @@ describe('host frame routing', () => {
|
||||
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S1 } }) // dup: ignored
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored
|
||||
expect(manager.getListSnapshot().items).toHaveLength(1)
|
||||
|
||||
const session = manager.get(S1)
|
||||
@@ -273,14 +275,14 @@ describe('remaining branches', () => {
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'published-later' as never,
|
||||
payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' },
|
||||
payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' },
|
||||
})
|
||||
expect(manager.getListSnapshot().items).toEqual([
|
||||
expect.objectContaining({ sessionId: S1, cwd: '/w/one' }),
|
||||
])
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'duplicate-frame' as never,
|
||||
payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' },
|
||||
payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' },
|
||||
})
|
||||
expect(manager.getListSnapshot().items).toHaveLength(1)
|
||||
})
|
||||
@@ -295,7 +297,7 @@ describe('remaining branches', () => {
|
||||
expect(notified).toBeGreaterThan(0)
|
||||
const seen = notified
|
||||
unsubscribe()
|
||||
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(notified).toBe(seen)
|
||||
})
|
||||
@@ -334,8 +336,8 @@ describe('remaining branches', () => {
|
||||
it('carries parentSessionId from host/session-added into the lineage row', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
|
||||
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1 } })
|
||||
const items = manager.getListSnapshot().items
|
||||
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
|
||||
})
|
||||
|
||||
193
packages/client/runtime/tests/queue-store.spec.ts
Normal file
193
packages/client/runtime/tests/queue-store.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Queue mirror semantics (web input-triggers queue cut 1): session/queued
|
||||
* intake, host-rule retirement (message turn/start claims oldest non-steering;
|
||||
* steering/message drains by source), leave-running sweep, reconnect reset,
|
||||
* pre-instantiation buffering, and snapshot reference stability.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
import { ev } from './event-script.ts'
|
||||
|
||||
const SID = 'fk-q1' as SessionId
|
||||
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
|
||||
const rid = (id: string): RpcId => id as RpcId
|
||||
|
||||
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
|
||||
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
|
||||
return {
|
||||
type: 'session/queued', sessionId: SID, content: text(body),
|
||||
source: { kind: 'user', rpcId: rid(rpcId) } as never, steering,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(): Session {
|
||||
return new Session(SID, new FakeApiClient())
|
||||
}
|
||||
|
||||
describe('queue intake', () => {
|
||||
it('lands a queued frame as a row keyed by the source rpcId with a flat preview', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1'))
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }])
|
||||
})
|
||||
|
||||
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-2'), {
|
||||
type: 'session/queued', sessionId: SID,
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
source: { kind: 'plugin', plugin: 'loop' }, steering: false,
|
||||
})
|
||||
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
|
||||
})
|
||||
|
||||
it('caps the preview at 200 code points with an ellipsis', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap'))
|
||||
const preview = session.getSnapshot().queue[0]?.preview ?? ''
|
||||
expect(Array.from(preview)).toHaveLength(201) // 200 + …
|
||||
expect(preview.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s'))
|
||||
const before = session.getSnapshot().queue
|
||||
session.handleAgentError('unrelated') // dirties the snapshot without touching the queue
|
||||
expect(session.getSnapshot().queue).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue retirement (host queuedMirror rules)', () => {
|
||||
it('a message-triggered turn/start claims the oldest non-steering row', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1'))
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2'))
|
||||
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) })
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2'])
|
||||
})
|
||||
|
||||
it('an injection-triggered turn/start claims nothing', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1'))
|
||||
const injection = {
|
||||
...ev.turnStart(0, 0),
|
||||
data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } },
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection })
|
||||
expect(session.getSnapshot().queue).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('steering/message drains the source-matched steering row only', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1'))
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('插话', 'p-2', true))
|
||||
// Loop-authored steering (different source) must not consume the user entry.
|
||||
const foreignSteering = {
|
||||
seq: 0, time: 1,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } },
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: foreignSteering })
|
||||
expect(session.getSnapshot().queue).toHaveLength(2)
|
||||
const matchedSteering = {
|
||||
seq: 1, time: 2,
|
||||
type: 'steering/message', surfaceOp: 'append',
|
||||
data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } },
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: matchedSteering })
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
|
||||
})
|
||||
|
||||
it('a leave-running flip sweeps the whole mirror (cancel/terminal-drop cover)', () => {
|
||||
const session = makeSession()
|
||||
session.handleRunning(true)
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1'))
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2', true))
|
||||
session.handleRunning(false)
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
})
|
||||
|
||||
it('a stale not-running relay on an idle session still sweeps replayed rows', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('孤儿', 'p-1'))
|
||||
session.handleRunning(false) // running already false: equality path must not skip the sweep
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue reconnect semantics', () => {
|
||||
it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old'))
|
||||
// New mux generation: subscribed arrives first on the same stream...
|
||||
session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
|
||||
expect(session.getSnapshot().queue).toEqual([])
|
||||
// ...then the queue snapshot replays the live inbox.
|
||||
session.handleMuxEnvelope(rid('e3'), queuedFrame('新基线', 'p-new'))
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-new'])
|
||||
})
|
||||
|
||||
it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => {
|
||||
const session = makeSession()
|
||||
// Reconnect ordering that broke: mux opened first and already delivered
|
||||
// the fresh generation's baseline; host stream (and with it onConnected →
|
||||
// resync) lands after. The host never resends — clearing here left the
|
||||
// dock empty until the next enqueue.
|
||||
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('新基线', 'p-fresh'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('manager buffering of queued frames', () => {
|
||||
it('buffers session/queued for uninstantiated sessions and replays before the running sync', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queuedFrame('预热', 'p-b1') })
|
||||
// Instantiation replays the buffer; no summary exists, so no running sweep runs.
|
||||
const session = manager.get(SID)
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-b1'])
|
||||
// The buffer is consumed: a second get must not double-replay.
|
||||
expect(manager.get(SID).getSnapshot().queue).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a not-running list summary sweeps replayed rows at instantiation', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok([{ sessionId: SID, updatedAt: 1, running: false }]))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queuedFrame('该扫掉', 'p-b2') })
|
||||
expect(manager.get(SID).getSnapshot().queue).toEqual([])
|
||||
})
|
||||
|
||||
it('subscribed re-baselines the uninstantiated buffer: stale queued frames drop, non-queue frames survive (regression: reconnect duplication)', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// Generation 1 baseline lands while the session is uninstantiated, along
|
||||
// with a pending approval (never re-derivable from history).
|
||||
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queuedFrame('第一代', 'p-g1') })
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: rid('g1b'),
|
||||
payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
|
||||
})
|
||||
// Reconnect: generation 2 replays subscribed + the SAME live queue entry.
|
||||
manager.handleMuxEnvelope({ rpcId: rid('g2a'), payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 } })
|
||||
manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queuedFrame('第一代', 'p-g1') })
|
||||
const snapshot = manager.get(SID).getSnapshot()
|
||||
// One queue row (no duplicate batch); the approval survived the re-baseline.
|
||||
expect(snapshot.queue.map(r => r.key)).toEqual(['p-g1'])
|
||||
expect(snapshot.pending.map(p => p.kind)).toEqual(['approval'])
|
||||
})
|
||||
})
|
||||
|
||||
/** ok wrapper with a typed items payload (the shared helper pins value to never[]). */
|
||||
function ok(items: { sessionId: SessionId; updatedAt: number; running: boolean }[]) {
|
||||
return { rpcId: rid(`ok-${items.length}`), result: { ok: true as const, value: { items: items as never[] } } }
|
||||
}
|
||||
84
packages/client/runtime/tests/scope.spec.ts
Normal file
84
packages/client/runtime/tests/scope.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Agent-scope primitive spec: the actx minted by createScope carries the
|
||||
* tag and the dispatch filter itself, so plain cordis dispatch with the actx
|
||||
* as subject routes by agent — same-agent tagged listeners receive,
|
||||
* foreign-agent ones are filtered out, untagged listeners hear everything,
|
||||
* and a subject-less root dispatch stays unfiltered. Scope-owned listeners
|
||||
* dispose with the fiber.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createScope, scopeOf } from '../src/client/agents/scope.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only routed probe event.
|
||||
* @param payload - marker payload.
|
||||
* @mode bail
|
||||
*/
|
||||
'test/scope-probe'(payload: { from: string }): true | undefined
|
||||
}
|
||||
}
|
||||
|
||||
function bench() {
|
||||
const root = new Context()
|
||||
const a = createScope(root, sid('a'))
|
||||
const b = createScope(root, sid('b'))
|
||||
const seen: string[] = []
|
||||
const listen = (label: string, ctx: Context, answer?: true) => {
|
||||
ctx.on('test/scope-probe', (payload) => {
|
||||
seen.push(`${label}:${payload.from}`)
|
||||
return answer
|
||||
})
|
||||
}
|
||||
return { root, a, b, seen, listen }
|
||||
}
|
||||
|
||||
describe('createScope', () => {
|
||||
it('tags the ctx (scopeOf) and leaves the root untagged', () => {
|
||||
const { root, a } = bench()
|
||||
expect(scopeOf(a.ctx)).toBe(sid('a'))
|
||||
expect(scopeOf(root)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('scoped dispatch reaches same-session and untagged listeners, never a foreign session', () => {
|
||||
const { root, a, b, seen, listen } = bench()
|
||||
listen('a', a.ctx)
|
||||
listen('b', b.ctx)
|
||||
listen('root', root)
|
||||
a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })
|
||||
expect(seen).toEqual(['a:a', 'root:a'])
|
||||
seen.length = 0
|
||||
b.ctx.emit(b.ctx, 'test/scope-probe', { from: 'b' })
|
||||
expect(seen).toEqual(['b:b', 'root:b'])
|
||||
})
|
||||
|
||||
it('bail answers the first same-scope listener and skips filtered foreign ones', () => {
|
||||
const { a, b, listen } = bench()
|
||||
listen('b', b.ctx, true) // registered first, but foreign → filtered out
|
||||
expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBeUndefined()
|
||||
listen('a', a.ctx, true)
|
||||
expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBe(true)
|
||||
})
|
||||
|
||||
it('a subject-less root dispatch is unfiltered (every listener hears it)', () => {
|
||||
const { root, a, b, seen, listen } = bench()
|
||||
listen('a', a.ctx)
|
||||
listen('b', b.ctx)
|
||||
listen('root', root)
|
||||
root.emit('test/scope-probe', { from: 'root' })
|
||||
expect(seen).toEqual(['a:root', 'b:root', 'root:root'])
|
||||
})
|
||||
|
||||
it('fiber disposal removes scope-owned listeners', async () => {
|
||||
const { a, seen, listen } = bench()
|
||||
listen('a', a.ctx)
|
||||
await a.fiber.dispose()
|
||||
a.ctx.emit(a.ctx, 'test/scope-probe', { from: 'late' })
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,191 +0,0 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '../src/client/sessions/service.ts'
|
||||
import { WorkspacesService } from '../src/client/workspaces/service.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
const wid = (id: string): WorkspaceId => id as WorkspaceId
|
||||
|
||||
function workspace(id: string, sessionIds: SessionId[] = []): WorkspaceView {
|
||||
return {
|
||||
workspaceId: wid(id),
|
||||
path: `/w/${id}`,
|
||||
title: id,
|
||||
sessionIds,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
async function ready(
|
||||
api: FakeApiClient,
|
||||
workspaces: WorkspacesService,
|
||||
sessions: SessionsService,
|
||||
workspaceRows: WorkspaceView[],
|
||||
sessionRows: { sessionId: SessionId; updatedAt: number; running: boolean }[] = [],
|
||||
): Promise<void> {
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: workspaceRows as never[] }))
|
||||
api.onList = () => Promise.resolve(ok({ items: sessionRows as never[] }))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function services(api: FakeApiClient): { sessions: SessionsService; workspaces: WorkspacesService } {
|
||||
const ctx = new Context()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
return { sessions, workspaces }
|
||||
}
|
||||
|
||||
function pendingPrompt(sessions: SessionsService, sessionId: SessionId) {
|
||||
return sessions.binding(sessionId)?.session.getSnapshot().pendingPrompt
|
||||
}
|
||||
|
||||
describe('frontend Session and Workspace intents', () => {
|
||||
it('resolves the initial intent into the most recently active Workspace', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
const old = workspace('old', [sid('s-old')])
|
||||
const recent = workspace('recent', [sid('s-recent')])
|
||||
await ready(api, workspaces, sessions, [old, recent], [
|
||||
{ sessionId: sid('s-old'), updatedAt: 1, running: false },
|
||||
{ sessionId: sid('s-recent'), updatedAt: 2, running: false },
|
||||
])
|
||||
expect(sessions.list.getSnapshot().intent).toMatchObject({
|
||||
target: { kind: 'workspace', workspaceId: 'recent' },
|
||||
phase: 'ready',
|
||||
})
|
||||
expect(workspaces.list.getSnapshot().intent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
await ready(api, workspaces, sessions, [])
|
||||
expect(workspaces.list.getSnapshot().intent).toMatchObject({ name: 'workspace', phase: 'ready' })
|
||||
sessions.updateIntent('first prompt')
|
||||
api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('created'), created: true }))
|
||||
api.onCreate = payload => Promise.resolve(ok({
|
||||
sessionId: (payload as { sessionId: SessionId }).sessionId,
|
||||
}))
|
||||
api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'prompt offline', details: {} }))
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => {
|
||||
const sessionId = sessions.list.getSnapshot().current as SessionId
|
||||
expect(pendingPrompt(sessions, sessionId)).toMatchObject({
|
||||
text: 'first prompt', phase: 'failed', retry: 'send',
|
||||
})
|
||||
})
|
||||
expect(api.callsOf('workspace.create')).toEqual([{ name: 'workspace' }])
|
||||
const create = api.callsOf('session.create')[0] as { workspaceId: WorkspaceId; sessionId: SessionId }
|
||||
expect(create.workspaceId).toBe('created')
|
||||
expect(api.callsOf('session.prompt')).toEqual([{
|
||||
sessionId: create.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'first prompt' }],
|
||||
}])
|
||||
expect(workspaces.list.getSnapshot().intent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('turns Workspace attachment failure into a focused real Session and retries its prompt', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
const target = workspace('target')
|
||||
await ready(api, workspaces, sessions, [target])
|
||||
sessions.updateIntent('keep this')
|
||||
api.onCreate = (payload) => {
|
||||
const sessionId = (payload as { sessionId: SessionId }).sessionId
|
||||
return Promise.resolve(err({
|
||||
code: 'workspace-attach-failed',
|
||||
message: 'attach rejected',
|
||||
details: { sessionId, workspaceId: target.workspaceId },
|
||||
}))
|
||||
}
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => {
|
||||
const snapshot = sessions.list.getSnapshot()
|
||||
expect(snapshot.intent).toBeUndefined()
|
||||
expect(pendingPrompt(sessions, snapshot.current as SessionId)).toMatchObject({
|
||||
text: 'keep this', phase: 'failed', retry: 'connect',
|
||||
})
|
||||
})
|
||||
const published = sessions.list.getSnapshot().current as SessionId
|
||||
const session = sessions.binding(published)!.session
|
||||
session.updatePendingPrompt('retry this')
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: published }))
|
||||
session.retryPendingPrompt()
|
||||
await vi.waitFor(() => {
|
||||
expect(pendingPrompt(sessions, published)).toBeNull()
|
||||
})
|
||||
expect(api.callsOf('session.prompt').at(-1)).toMatchObject({
|
||||
sessionId: published,
|
||||
content: [{ type: 'text', text: 'retry this' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not send after navigation while Session creation is in flight', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
const target = workspace('target')
|
||||
await ready(api, workspaces, sessions, [target])
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onCreate']>>>()
|
||||
api.onCreate = () => gate.promise
|
||||
sessions.updateIntent('do not send yet')
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => { expect(api.callsOf('session.create')).toHaveLength(1) })
|
||||
const requested = (api.callsOf('session.create')[0] as { sessionId: SessionId }).sessionId
|
||||
workspaces.startSession(target.workspaceId)
|
||||
const replacement = sessions.list.getSnapshot().intent!
|
||||
gate.resolve(ok({ sessionId: requested }))
|
||||
await vi.waitFor(() => {
|
||||
expect(pendingPrompt(sessions, requested)).toMatchObject({
|
||||
text: 'do not send yet', phase: 'failed', retry: 'send',
|
||||
})
|
||||
})
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
expect(sessions.list.getSnapshot()).toMatchObject({
|
||||
current: replacement.sessionId,
|
||||
intent: { sessionId: replacement.sessionId },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a lost-response Intent and retries creation with its preallocated id', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const { sessions, workspaces } = services(api)
|
||||
const target = workspace('target')
|
||||
await ready(api, workspaces, sessions, [target])
|
||||
sessions.updateIntent('preserve me')
|
||||
api.onCreate = () => Promise.reject(new Error('response lost'))
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => {
|
||||
expect(sessions.list.getSnapshot().intent?.error).toMatchObject({ step: 'session' })
|
||||
})
|
||||
const requested = sessions.list.getSnapshot().intent?.sessionId as SessionId
|
||||
sessions.handleHostEnvelope({
|
||||
rpcId: 'published-later' as never,
|
||||
payload: { type: 'host/session-added', sessionId: requested, cwd: target.path },
|
||||
})
|
||||
expect(sessions.list.getSnapshot()).toMatchObject({
|
||||
current: requested,
|
||||
intent: { sessionId: requested, error: { step: 'session' } },
|
||||
})
|
||||
expect(sessions.intent()?.getSnapshot().pendingPrompt).toMatchObject({
|
||||
text: 'preserve me', phase: 'editing',
|
||||
})
|
||||
|
||||
api.onCreate = payload => Promise.resolve(ok({
|
||||
sessionId: (payload as { sessionId: SessionId }).sessionId,
|
||||
}))
|
||||
workspaces.sendSession()
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.create')).toHaveLength(2)
|
||||
expect(api.callsOf('session.prompt')).toHaveLength(1)
|
||||
expect(sessions.list.getSnapshot()).toMatchObject({ current: requested, intent: undefined })
|
||||
expect(pendingPrompt(sessions, requested)).toBeNull()
|
||||
})
|
||||
expect(api.callsOf('session.create').map(call => (call as { sessionId: SessionId }).sessionId))
|
||||
.toEqual([requested, requested])
|
||||
})
|
||||
})
|
||||
@@ -853,6 +853,95 @@ describe('resync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch indexing', () => {
|
||||
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
|
||||
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
|
||||
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
|
||||
const live = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(live).toHaveLength(2)
|
||||
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
|
||||
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
|
||||
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
|
||||
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
|
||||
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
|
||||
const mixed = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
|
||||
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
|
||||
// The settle carries the paired start's time as callTime (duration source).
|
||||
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
|
||||
const settled = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
|
||||
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
|
||||
})
|
||||
|
||||
it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
|
||||
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
|
||||
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
|
||||
const subs = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(subs).toHaveLength(2)
|
||||
expect(subs?.[0]).toMatchObject({
|
||||
kind: 'tool-result', callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' },
|
||||
// The settle event carries no start time: callTime stays null (never a
|
||||
// fabricated zero-duration).
|
||||
callTime: null,
|
||||
isError: false, content: [{ type: 'text', text: 'demo.txt' }],
|
||||
})
|
||||
expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true })
|
||||
// No paired start in the window: duration is UNKNOWN (null), never a
|
||||
// fabricated zero-duration span.
|
||||
expect(subs?.[0]).toMatchObject({ callTime: null })
|
||||
// Sub-dispatches never join the surface flow.
|
||||
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
|
||||
})
|
||||
|
||||
it('rebuilds the same index from a history window (replay parity)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse([
|
||||
...plainTurn(0, 0, '问', '答'),
|
||||
ev.turnStart(6, 1),
|
||||
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
|
||||
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'),
|
||||
ev.toolResult(9, 1, 'p1', '{"done":true}'),
|
||||
ev.turnEnd(10, 1),
|
||||
])
|
||||
await session.open()
|
||||
const subs = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(subs).toHaveLength(1)
|
||||
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
|
||||
})
|
||||
|
||||
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
|
||||
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
|
||||
const before = session.getSnapshot()
|
||||
feed(ev.chunkStart(9, 1))
|
||||
feed(ev.chunkText(10, 1, '流式'))
|
||||
const after = session.getSnapshot()
|
||||
expect(after.codeDispatches).toBe(before.codeDispatches)
|
||||
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
|
||||
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
|
||||
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reference stability (the memo contract)', () => {
|
||||
it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
|
||||
const { api, session } = makeSession()
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
|
||||
import { FakeApiClient, ok } from './fake-api.ts'
|
||||
import { FakeApiClient, deferred, ok } from './fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
@@ -28,10 +28,12 @@ function bench(): Bench {
|
||||
}
|
||||
|
||||
/** Refresh the manager list from programmable rows and flush the microtask batch. */
|
||||
async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean }[]): Promise<void> {
|
||||
type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
|
||||
|
||||
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
|
||||
b.api.onList = () => Promise.resolve(ok({
|
||||
items: rows.map(r => ({
|
||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
|
||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
|
||||
})),
|
||||
@@ -61,7 +63,7 @@ describe('list store projection', () => {
|
||||
it('reflects live increments (host stream via manager) into the store', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
|
||||
b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', blank: true, sessionId: sid('s2') } as never })
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().ids).toContain('s2')
|
||||
})
|
||||
@@ -77,7 +79,7 @@ describe('scope tree', () => {
|
||||
expect(scopeOf(scoped as Context)).toBe('s1')
|
||||
expect(scopeOf(b.ctx)).toBeUndefined()
|
||||
const binding = b.svc.binding(sid('s1'))
|
||||
expect(binding?.session).toBe(b.svc.cell('s1')?.session)
|
||||
expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
|
||||
expect(b.svc.binding(sid('s1'))).toBe(binding)
|
||||
expect(binding?.ctx).toBe(scoped)
|
||||
})
|
||||
@@ -184,20 +186,20 @@ describe('cell (render-layer session kit)', () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const cell = b.svc.cell('s1')
|
||||
expect(cell).toBeDefined()
|
||||
expect(cell?.sessionId).toBe('s1')
|
||||
// The cell carries the observable; hook binding happens in React.
|
||||
expect(cell?.session).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(b.svc.cell('s1')).toBe(cell)
|
||||
expect(b.svc.cell('ghost')).toBeUndefined()
|
||||
const info = b.svc.provideInfo('s1')
|
||||
expect(info).toBeDefined()
|
||||
expect(info?.sessionId).toBe('s1')
|
||||
// The bundle carries bare observables; hook binding happens in React.
|
||||
expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(b.svc.provideInfo('s1')).toBe(info)
|
||||
expect(b.svc.provideInfo('ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.open(sid('s1')) // staged
|
||||
b.svc.cell('s2') // resolution only — must NOT move the stage
|
||||
b.svc.provideInfo('s2') // resolution only — must NOT move the stage
|
||||
b.svc.binding(sid('s2'))
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
@@ -209,7 +211,7 @@ describe('cell (render-layer session kit)', () => {
|
||||
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
|
||||
// Resolution is addressing, not staging: no window pull.
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.cell('s1')
|
||||
b.svc.provideInfo('s1')
|
||||
b.svc.binding(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(0)
|
||||
b.svc.open(sid('s1'))
|
||||
@@ -296,12 +298,24 @@ describe('create', () => {
|
||||
const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(SessionCreateError)
|
||||
expect(failure).toMatchObject({
|
||||
requestedSessionId: 'candidate', publishedSessionId: undefined,
|
||||
requestedSessionId: 'candidate',
|
||||
rpcError: { code: 'internal', message: '爆了' },
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces the definitely published id after Workspace attachment fails', async () => {
|
||||
it('resolves with the session already listed and binding-resolvable (no flush wait)', async () => {
|
||||
const b = bench()
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('born') }))
|
||||
const born = await b.svc.create({ workspaceId: 'ws' as never })
|
||||
// Synchronously after resolution — the draft hand-off contract: the
|
||||
// create echo IS the entity entering the client's view (blank row +
|
||||
// resolvable scope/binding), no notifier flush in between.
|
||||
expect(b.svc.list.getSnapshot().byId[born]).toMatchObject({ id: 'born', blank: true })
|
||||
expect(b.svc.binding(born)).toBeDefined()
|
||||
expect(b.svc.scope(born)).toBeDefined()
|
||||
})
|
||||
|
||||
it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => {
|
||||
const b = bench()
|
||||
b.api.onCreate = () => Promise.resolve({
|
||||
rpcId: 'attach' as never,
|
||||
@@ -318,11 +332,111 @@ describe('create', () => {
|
||||
sessionId: sid('published'),
|
||||
}).catch((error: unknown) => error)
|
||||
await Promise.resolve()
|
||||
expect(failure).toBeInstanceOf(SessionCreateError)
|
||||
expect(failure).toMatchObject({
|
||||
publishedSessionId: 'published', requestedSessionId: 'published',
|
||||
requestedSessionId: 'published',
|
||||
rpcError: { code: 'workspace-attach-failed' },
|
||||
})
|
||||
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published' })
|
||||
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
|
||||
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [])
|
||||
expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'add' as never,
|
||||
payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
const scoped = b.svc.scope(sid('s-new'))
|
||||
expect(scoped).toBeDefined()
|
||||
expect(scopeOf(scoped as Context)).toBe('s-new')
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'rm' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: sid('s-new') },
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(b.svc.scope(sid('s-new'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('blank mirror', () => {
|
||||
it('flips blank=false from the running:true status frame (cross-client conversion)', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true }])
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'st' as never,
|
||||
payload: { type: 'host/session-status', sessionId: sid('s1'), running: true },
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true })
|
||||
// The instantiated Session mirrors the same flip.
|
||||
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
|
||||
})
|
||||
|
||||
it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
|
||||
const session = b.svc.binding(sid('s1'))!.session
|
||||
expect(session.getSnapshot().blank).toBe(true)
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onPrompt']>>>()
|
||||
b.api.onPrompt = () => gate.promise
|
||||
const send = session.prompt([{ type: 'text', text: 'hi' }], 'queue')
|
||||
// In flight: still blank (the flip point is the success response, which
|
||||
// proves the user message reached the host log).
|
||||
expect(session.getSnapshot().blank).toBe(true)
|
||||
gate.resolve(ok({ accepted: true as const }))
|
||||
await send
|
||||
expect(session.getSnapshot().blank).toBe(false)
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
|
||||
})
|
||||
|
||||
it('keeps a rejected first prompt blank: hidden and still reusable', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
|
||||
const session = b.svc.binding(sid('s1'))!.session
|
||||
b.api.onPrompt = () => Promise.resolve({
|
||||
rpcId: 'busy' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } },
|
||||
} as never)
|
||||
const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
|
||||
expect(result.ok).toBe(false)
|
||||
// No flip on failure: local stays aligned with the host authority
|
||||
// (events.length still 0), so the session stays hidden and reusable.
|
||||
expect(session.getSnapshot().blank).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
|
||||
})
|
||||
|
||||
it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [])
|
||||
b.svc.handleHostEnvelope({
|
||||
rpcId: 'add' as never,
|
||||
payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true })
|
||||
// Reconnect re-pull: the summary's blank=false wins (authoritative alignment).
|
||||
await feedList(b, [{ id: 's-new', blank: false, cwd: '/w/a' }])
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: false })
|
||||
})
|
||||
|
||||
it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true }])
|
||||
const session = b.svc.binding(sid('s1'))!.session
|
||||
await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
|
||||
await Promise.resolve()
|
||||
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
|
||||
// The next list pull still claims blank (host hasn't logged the message yet).
|
||||
await feedList(b, [{ id: 's1', blank: true }])
|
||||
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -97,13 +97,17 @@ function fakeWorkspaces() {
|
||||
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
|
||||
}
|
||||
|
||||
/** Minimal sessions face for the host seam (list observable + cell). */
|
||||
/** Minimal sessions face for the host seam (list observable + provide bundle). */
|
||||
function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
return {
|
||||
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||
cell: (id: string) => (id === 'known'
|
||||
? { sessionId: id, session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }
|
||||
provideInfo: (id: string) => (id === 'known'
|
||||
? {
|
||||
sessionId: id,
|
||||
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
|
||||
props: {},
|
||||
}
|
||||
: undefined),
|
||||
}
|
||||
}
|
||||
@@ -228,13 +232,13 @@ describe('host face', () => {
|
||||
expect(host.entriesOf('t.host')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('exposes sessions list/current/cell (current riding the list snapshot)', async () => {
|
||||
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench)
|
||||
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
|
||||
expect(host.sessions.current.getSnapshot()).toBeUndefined()
|
||||
expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.cell('ghost')).toBeUndefined()
|
||||
expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes the independent Workspace list source', async () => {
|
||||
|
||||
55
packages/client/runtime/tests/wire-events.spec.ts
Normal file
55
packages/client/runtime/tests/wire-events.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Wire-to-typed-event bridge (web input-triggers cut 1): host/commands-changed
|
||||
* → ctx 'commands/changed'; each established connection generation →
|
||||
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import * as RuntimeClient from '../src/client/index.ts'
|
||||
import { FakeApiClient } from './fake-api.ts'
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
sinks: ConnectionSinks | undefined
|
||||
}
|
||||
|
||||
async function mount(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('connection', handle)
|
||||
await ctx.plugin(RuntimeClient).await()
|
||||
return bench
|
||||
}
|
||||
|
||||
describe('wire event bridge', () => {
|
||||
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
|
||||
const bench = await mount()
|
||||
let changed = 0
|
||||
bench.ctx.on('commands/changed', () => { changed++ })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
|
||||
expect(changed).toBe(1)
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
rpcId: 'r2' as never,
|
||||
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
|
||||
})
|
||||
expect(changed).toBe(1)
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
bench.ctx.on('connection/reset', () => { resets++ })
|
||||
bench.sinks?.onConnected?.()
|
||||
bench.sinks?.onConnected?.() // second generation after a reconnect
|
||||
expect(resets).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -17,38 +17,6 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0
|
||||
}
|
||||
|
||||
describe('WorkspaceManager', () => {
|
||||
it('owns, materializes, retries, supersedes, and discards Workspace objects with local intents', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new WorkspaceManager(api)
|
||||
manager.startIntent('first')
|
||||
expect(manager.getSnapshot().intent).toEqual({ name: 'first', phase: 'ready' })
|
||||
|
||||
api.onWorkspaceCreate = () => Promise.resolve(err({
|
||||
code: 'workspace-name-conflict', message: 'taken', details: { name: 'first' },
|
||||
} as never))
|
||||
await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: false })
|
||||
expect(manager.getSnapshot().intent).toMatchObject({ name: 'first', phase: 'ready' })
|
||||
expect(typeof manager.getSnapshot().intent?.error).toBe('string')
|
||||
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceCreate']>>>()
|
||||
api.onWorkspaceCreate = () => gate.promise
|
||||
const stale = manager.materializeIntent()
|
||||
expect(manager.getSnapshot().intent?.phase).toBe('creating')
|
||||
manager.startIntent('replacement')
|
||||
gate.resolve(ok({ workspace: workspace('first'), created: true }))
|
||||
await stale
|
||||
expect(manager.getSnapshot().intent).toEqual({ name: 'replacement', phase: 'ready' })
|
||||
|
||||
api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('replacement'), created: true }))
|
||||
await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: true })
|
||||
expect(manager.getSnapshot().intent).toBeUndefined()
|
||||
await expect(manager.materializeIntent()).resolves.toBeUndefined()
|
||||
manager.discardIntent()
|
||||
manager.startIntent('discarded')
|
||||
manager.discardIntent()
|
||||
expect(manager.getSnapshot().intent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replays changed frames over hydration and keeps established order on refresh', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
@@ -111,7 +79,7 @@ describe('WorkspaceManager', () => {
|
||||
})
|
||||
|
||||
describe('WorkspacesService', () => {
|
||||
it('feeds SessionManager readiness and recent-Workspace targeting without changing Host order', async () => {
|
||||
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
@@ -127,7 +95,7 @@ describe('WorkspacesService', () => {
|
||||
expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
|
||||
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false }] as never[],
|
||||
items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false, blank: false }] as never[],
|
||||
}))
|
||||
await sessions.refresh()
|
||||
await Promise.resolve()
|
||||
@@ -136,12 +104,65 @@ describe('WorkspacesService', () => {
|
||||
baselinesReady: true,
|
||||
recentWorkspaceId: 'active',
|
||||
})
|
||||
expect(sessions.list.getSnapshot().intent).toMatchObject({
|
||||
target: { kind: 'workspace', workspaceId: 'active' },
|
||||
})
|
||||
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
|
||||
})
|
||||
|
||||
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({
|
||||
items: [workspace('alpha'), workspace('beta')] as never[],
|
||||
}))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
// Blank session already parked in alpha (cwd == workspace path canon).
|
||||
{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
|
||||
// Non-blank sibling in beta must never be reused.
|
||||
{ sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
|
||||
] as never[],
|
||||
}))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
|
||||
// Hit: same workspace → the parked blank session comes back, no create RPC.
|
||||
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
|
||||
expect(api.callsOf('session.create')).toEqual([])
|
||||
// Resolution guarantee: the id is binding-resolvable synchronously.
|
||||
expect(sessions.binding(sid('s-blank'))).toBeDefined()
|
||||
|
||||
// Miss: beta has only a non-blank session → host create with workspaceId.
|
||||
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
|
||||
await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
|
||||
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }])
|
||||
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
|
||||
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
|
||||
|
||||
// Unknown workspace fails loud instead of silently creating in nowhere.
|
||||
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
|
||||
})
|
||||
|
||||
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
|
||||
}))
|
||||
await Promise.all([workspaces.refresh(), sessions.refresh()])
|
||||
await Promise.resolve()
|
||||
const session = sessions.binding(sid('s-blank'))!.session
|
||||
api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never)
|
||||
await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
|
||||
await Promise.resolve()
|
||||
// Failure leaves blank intact, so the same session is still the reuse hit.
|
||||
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
|
||||
expect(api.callsOf('session.create')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns created Workspaces and preserves Host business errors', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -33,7 +33,7 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|
|
||||
* Documented TEMPORARY exemption, not a platform module (hence not in
|
||||
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
|
||||
* shallowEqual) lives in runtime pending its promotion-time rehoming, and
|
||||
* five importers (i18n, ui-layout, ui-conversation ×3) ride this single
|
||||
* five importers (locale, ui-layout, ui-conversation ×3) ride this single
|
||||
* exemption. At runtime the lazy CJS table answers the require natively:
|
||||
* runtime is an immediately-tier row, its factory is registered before any
|
||||
* dependent bundle materializes. TODO(webload/store-rehome): remove with the
|
||||
|
||||
6
packages/client/ui-command/README.i18n.yaml
Normal file
6
packages/client/ui-command/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 17bc4edd7d002d6bba4470c9418a9179b2cb131b
|
||||
README.zh.md: 1291556409b993aa893e102386f75c45bb195adf
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user